diff --git a/.docker/certs/Cenobit_Root_CA.pem b/.docker/certs/Cenobit_Root_CA.pem index f2f572fc..f35985d4 100644 --- a/.docker/certs/Cenobit_Root_CA.pem +++ b/.docker/certs/Cenobit_Root_CA.pem @@ -1,30 +1,29 @@ -----BEGIN CERTIFICATE----- -MIIFFzCCA3+gAwIBAgIQOWWFM0qvic8JSw9z/XMW+zANBgkqhkiG9w0BAQsFADCB -ozEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMTwwOgYDVQQLDDNueWNo -b2xhc0BkcmVhbWVyLmxvY2FsIChOeWNob2xhcyBPbGl2ZWlyYSBPbGl2ZWlyYSkx -QzBBBgNVBAMMOm1rY2VydCBueWNob2xhc0BkcmVhbWVyLmxvY2FsIChOeWNob2xh -cyBPbGl2ZWlyYSBPbGl2ZWlyYSkwHhcNMjEwNjE3MTMwMjQ1WhcNMzEwNjE3MTMw -MjQ1WjCBozEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMTwwOgYDVQQL -DDNueWNob2xhc0BkcmVhbWVyLmxvY2FsIChOeWNob2xhcyBPbGl2ZWlyYSBPbGl2 -ZWlyYSkxQzBBBgNVBAMMOm1rY2VydCBueWNob2xhc0BkcmVhbWVyLmxvY2FsIChO -eWNob2xhcyBPbGl2ZWlyYSBPbGl2ZWlyYSkwggGiMA0GCSqGSIb3DQEBAQUAA4IB -jwAwggGKAoIBgQC/VgcpUwHdpe05ivn12+a0q71JaCcbhrSJYXFa3zXTKjymOYS8 -88iEL9X671Dwl+i9phP8bkq1E9HNgFglm9IYbZ6cfdrfc8CH2L+XnZdL5glnHZdz -zTmA1iroWBTw70Y1eMUWjALK4Ib0A7qTHu5kM1gtGojYc1j1Yp4cBm6JdA/mk/Mh -/iWj+voSOeHrjYBbwUioXzVshCQsUgdc4SeH3IGp1WzvJeVI9Te+cFitssvAtI4C -IZboi/wlutjq66jhJp6JUOToXkJ/4kBrh1iTfYOWnQBP3gFG7PqAoiVc5uo+PQ/w -Yx2BilSdc0XfsLhepI1Xg1kp67pd+xf+Jw9mNdqHCYn17ldR2TXo1u8MVmEzt9qs -H7lKFh32RWOsvhFwlIIFdRGj5K6Hg16H3fZot6lpOO5yVyslettlItVWGW+bPEas -hFTX4quDscIJkRXA1yO4i+ZcsP6hWiX3KVbWVHzPd8hLOzQ3H8qvduZUROIej4b9 -pFoNdePFVkORs+ECAwEAAaNFMEMwDgYDVR0PAQH/BAQDAgIEMBIGA1UdEwEB/wQI -MAYBAf8CAQAwHQYDVR0OBBYEFIliX3yj0TrwRJ78zRyNCAev611aMA0GCSqGSIb3 -DQEBCwUAA4IBgQC3y+3/58Z3SZ3myaJWhrkJGdzFGVC4IHnYopptCZIJDXnt4dnX -nvTcoUZ9YVojFkhWVFo/XXecr/PJY/lgqOaPbw1t2Ah5pVgmAR8SD8FVH7vRmFWk -arlf0GXGonwTg/hhv+Fdt6HcjN+bg8XjRbCIJWGmuevnxoZru9HAcATRrhNRmsQ2 -xSUg/jU3k5is7yl/UFXVnElejD9wXMWFNkdpXCV2H81CQAwpSW0mTrq31FUpnHPX -qqVa77OQo2HFQdNwEnqPlBqR9wOGuSn2LrRCm1V+rAF8UhPybU3BesoKk+lWv7wc -0e37Er3sxTzBkdnWQy3/8JSrqyUzn+xlWQ3KkM9rrC28VGQkTx83Dp7ln0ad9vNd -qK0i7xDv4iX7lbeJ38/qAwB5Lfnaw5SNhmVkv+CZKYHPnleixPUrlhP3w7KhVz4r -nAEz1HYZ3pi/xqeWln0D+UfehUc42pcGIW1Ia+CxRut8DM1Mu1KJoSxh9dsk1n7I -cowv7zPb0KBbtJM= +MIIFAzCCA2ugAwIBAgIQUdO9J9v9Ytes8ORgYCy+NDANBgkqhkiG9w0BAQsFADCB +mTEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMTcwNQYDVQQLDC5ueWNo +b2xhc0BOeWNob2xhcy1PbGl2ZWlyYSAoTnljaG9sYXMgT2xpdmVpcmEpMT4wPAYD +VQQDDDVta2NlcnQgbnljaG9sYXNATnljaG9sYXMtT2xpdmVpcmEgKE55Y2hvbGFz +IE9saXZlaXJhKTAeFw0yNDEwMjcxMzI3MDBaFw0zNDEwMjcxMzI3MDBaMIGZMR4w +HAYDVQQKExVta2NlcnQgZGV2ZWxvcG1lbnQgQ0ExNzA1BgNVBAsMLm55Y2hvbGFz +QE55Y2hvbGFzLU9saXZlaXJhIChOeWNob2xhcyBPbGl2ZWlyYSkxPjA8BgNVBAMM +NW1rY2VydCBueWNob2xhc0BOeWNob2xhcy1PbGl2ZWlyYSAoTnljaG9sYXMgT2xp +dmVpcmEpMIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAvoWKEX9rbh+5 +lrYxEg0PZVxTI8VuOV/d3Gl52xyF9VBsbUvlxEzZTjaDKxNbTPAoIwFm7uTA/HA9 +wQhL9b2j/K3dDM4SMPUxIk+hcTiIcbmmcMuAt8+qqK699o/lMYYFxoYaEp1FeWYC +rCcCVBAzjtO2IPmUOclJLuTirbLm3zNMi5G4KbaHTsXSk96hlJfgCggM60iR20aA +b46YNMjO3Ir7GMmWzYEUKrcvnwGC5mnfFBl31v7MOf9rIPcqSV9bAIg8WIH+HR0w +Ecv93azb7P/oo6lqyzK9OH5prU8UHxGfMBx+DjcdWbPeS8tH83TCu1gZKkakoJad +AujGF2TzH/QeSektQiHsjjcIppvU7q197QPHUtB3yjgQ95cXqZZV1zzZ78EdDpAa +iGD5cBDzsk5Kv+jEC+BSzk/5UouUDsJzdZNusSEdoCKUefIMEvHtiJP+pZgDD6Y6 +ROln24IBNF5nDT5ucoSjDt5hotKCwt9WMn8isvlMJ58dllc4TO59AgMBAAGjRTBD +MA4GA1UdDwEB/wQEAwICBDASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSa +uvufBNBaHBL858MXfjisUs5i7TANBgkqhkiG9w0BAQsFAAOCAYEAi8TTqRBYWLlR +z5O/YO6A9qFmdO4AvTjTwc2raHJK0iSRvwVe42ZL48AFGYYmw68OJj1hQjtOttXn +Onpd7jFaHCcH2+VkGWNcP4dDWOoWxqCg2Pj2iAexQo+7Kn9hBwFYFcR8dNrGdjmk +tjpvnNGwUdzq0MFdblyCiFPofWFrJL6AVyNNfn+KnqNKdjf5M2+yMnqtqXiHyAK0 +hFyuXY4LPeQXrL+kVPnKI5Wmg2104CET9G5UqoRMSFbTI00+hT4f1AlP6m8i6pP3 +LQYIBJ8HhTCtZMYwmFZBZtLiv9rc4keGq22hBpPb2WnC8aDUw7WDkFxebVE4mdIa +xL8OTEkNjyEY4bGlOWSVcrImN9KouRo6x5Jzb7PyFOhglemtZirYL/nM9henqmlM +cgc41/ApvvK949JgLjhZjXaWkg3iG6jemUM79jkdF/FCk1MIRbPiJEU4L5OYGk90 +hL/aQquSD9AfCpBOkioz8ennjewnZW2xqGCoYpmq2OF+pkRXYIb+ -----END CERTIFICATE----- diff --git a/.docker/nginx/README.md b/.docker/nginx/README.md index 8b0371cc..d14acd82 100644 --- a/.docker/nginx/README.md +++ b/.docker/nginx/README.md @@ -12,6 +12,6 @@ $ openssl dhparam -out ssl/ssl-dhparam.pem 4096 See [here](https://github.com/FiloSottile/mkcert#installation) how to install mkcert. ``` $ mkcert -install -$ mkcert -key-file ssl/flask-jsonrpc.cenobit.es.key -cert-file ssl/flask-jsonrpc.cenobit.es.crt *.flask-jsonrpc.cenobit.es -$ cp -rf "$(mkcert -CAROOT)/rootCA.pem" ../certs/ +$ mkcert -key-file ssl/flask-jsonrpc.cenobit.es.key -cert-file ssl/flask-jsonrpc.cenobit.es.crt '*.flask-jsonrpc.cenobit.es' +$ cp -rf "$(mkcert -CAROOT)/rootCA.pem" ../certs/Cenobit_Root_CA.pem ``` diff --git a/.docker/nginx/conf.d/async-app.flask-jsonrpc.cenobit.es.conf b/.docker/nginx/conf.d/async-app.flask-jsonrpc.cenobit.es.conf index d0a87bac..bbce450d 100644 --- a/.docker/nginx/conf.d/async-app.flask-jsonrpc.cenobit.es.conf +++ b/.docker/nginx/conf.d/async-app.flask-jsonrpc.cenobit.es.conf @@ -10,7 +10,7 @@ server { server_name async-app.flask-jsonrpc.cenobit.es; location / { - proxy_pass http://async_app:5000/; + proxy_pass http://async-app:5000/; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $host; diff --git a/.docker/nginx/conf.d/mypyc-app.flask-jsonrpc.cenobit.es.conf b/.docker/nginx/conf.d/mypyc-app.flask-jsonrpc.cenobit.es.conf index a8455dca..158afba9 100644 --- a/.docker/nginx/conf.d/mypyc-app.flask-jsonrpc.cenobit.es.conf +++ b/.docker/nginx/conf.d/mypyc-app.flask-jsonrpc.cenobit.es.conf @@ -10,7 +10,7 @@ server { server_name mypyc-app.flask-jsonrpc.cenobit.es; location / { - proxy_pass http://mypyc_app:5000/; + proxy_pass http://mypyc-app:5000/; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $host; diff --git a/.docker/nginx/conf.d/mypyc-async-app.flask-jsonrpc.cenobit.es.conf b/.docker/nginx/conf.d/mypyc-async-app.flask-jsonrpc.cenobit.es.conf deleted file mode 100644 index 0a4ea6b4..00000000 --- a/.docker/nginx/conf.d/mypyc-async-app.flask-jsonrpc.cenobit.es.conf +++ /dev/null @@ -1,28 +0,0 @@ -server { - listen 443 ssl; - listen [::]:443 ssl; - - ssl_certificate /etc/nginx/ssl/flask-jsonrpc.cenobit.es.crt; - ssl_certificate_key /etc/nginx/ssl/flask-jsonrpc.cenobit.es.key; - - include ssl/ssl-params.conf; - - server_name mypyc-async-app.flask-jsonrpc.cenobit.es; - - location / { - proxy_pass http://mypyc_async_app:5000/; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $host; - proxy_set_header X-Forwarded-Prefix /; - } -} - -server { - listen 80; - listen [::]:80; - - server_name mypyc-async-app.flask-jsonrpc.cenobit.es; - - return 302 https://$server_name$request_uri; -} diff --git a/.docker/nginx/nginx.conf b/.docker/nginx/nginx.conf index e2944099..458e797b 100644 --- a/.docker/nginx/nginx.conf +++ b/.docker/nginx/nginx.conf @@ -11,6 +11,11 @@ http { root /opt/test-static/public; index index.html; + + location /health { + default_type application/json; + return 200 '{"status": "UP"}'; + } } include /etc/nginx/conf.d/*.conf; diff --git a/.docker/nginx/ssl/flask-jsonrpc.cenobit.es.crt b/.docker/nginx/ssl/flask-jsonrpc.cenobit.es.crt index 78a01086..89d69f91 100644 --- a/.docker/nginx/ssl/flask-jsonrpc.cenobit.es.crt +++ b/.docker/nginx/ssl/flask-jsonrpc.cenobit.es.crt @@ -1,27 +1,26 @@ -----BEGIN CERTIFICATE----- -MIIEjjCCAvagAwIBAgIRAJdntdMRAvSoHdaDsHUxPPkwDQYJKoZIhvcNAQELBQAw -gaMxHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTE8MDoGA1UECwwzbnlj -aG9sYXNAZHJlYW1lci5sb2NhbCAoTnljaG9sYXMgT2xpdmVpcmEgT2xpdmVpcmEp -MUMwQQYDVQQDDDpta2NlcnQgbnljaG9sYXNAZHJlYW1lci5sb2NhbCAoTnljaG9s -YXMgT2xpdmVpcmEgT2xpdmVpcmEpMB4XDTIzMDMyOTExMTAxOFoXDTI1MDYyOTEx -MTAxOFowcDEnMCUGA1UEChMebWtjZXJ0IGRldmVsb3BtZW50IGNlcnRpZmljYXRl -MUUwQwYDVQQLDDxueWNob2xhc0BCUjM2MDAwMk1CUDE2SU40LmxvY2FsIChOeWNo -b2xhcyBPbGl2ZWlyYSBPbGl2ZWlyYSkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw -ggEKAoIBAQDAzjlsHOl7sq9GpCdeNcP9NYKfjBm6SZQviJ/xDJTFQEsQuJ9t85oW -SsLI0SgAXO+o2XndrgjCE+u5CEiRzHnTAb/OHQH4Sp3JizbXnDcRZHOp1ZJPb9Xl -ZiQ7tGFFkm5Dah1Vh5eV1wTfQejfiA4XoN5DY67HkAy1+TRQ7TTXu1C7cG5DMEfQ -7FJaDNKHwQjPtiEoGCoWH0iRy2Z8P0mu0plx8Yuqo5JKWox9vuFEnndhN7kat4I3 -QN6FEnOZSltKx9iUImew8iTWiTgMEYguqWiZqVc2lxE0U7gyQueMFGraPIJT23oS -j5W6eCxfQ93NKFwF4AmqCm/Hmh344TIjAgMBAAGjbzBtMA4GA1UdDwEB/wQEAwIF -oDATBgNVHSUEDDAKBggrBgEFBQcDATAfBgNVHSMEGDAWgBSJYl98o9E68ESe/M0c -jQgHr+tdWjAlBgNVHREEHjAcghoqLmZsYXNrLWpzb25ycGMuY2Vub2JpdC5lczAN -BgkqhkiG9w0BAQsFAAOCAYEAJ1b/TDRnPk1y0uRIhLdwjtnIdpoTK5sgii5uKXAN -SDdVM+5MPjqv53AeVw7SbylZtkQD3T3QTXHMhRbRl4cxWLV+H/uqkXuClUyWUK2V -AD66v/rlz6A6f2G/K2+JDLWnwuaVgBDOEOC5b4fFICLJMPmeY1VAz11VoMlCiYSC -uRhKubZYHvkGGGvLy/G23jp042+Z3B/X8ofZG8eBXgW/gOfzRKS63wKzK5u58AZ0 -/PzCWb33+KCPfRmwl7a6qCRbN8kiMJGfVYF7teKZ9RZ+LpSb1tCOUjzkEKtxlBkW -HManFwJL8hkhMoYct1UUobzXD4/uUtnCnS/kFHLfj2611dqjPTyE5/KGsSIEONAc -xgZim6dR/B9xT8ug6d7t7VS2VP10vckw6B9m47QOCyEYJZqHZgma3J1sAk6J5pI/ -fgoA2cgXCo+ybnqvrCUpf+COHjO1KP8m4OjuiGGBu01AAfqq7EVV1ypudZZZYWdx -4suMPBGRXjprL/iJu1qsnRlu +MIIEdTCCAt2gAwIBAgIQITwueSWGbuk1lC3g7lXR+TANBgkqhkiG9w0BAQsFADCB +mTEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMTcwNQYDVQQLDC5ueWNo +b2xhc0BOeWNob2xhcy1PbGl2ZWlyYSAoTnljaG9sYXMgT2xpdmVpcmEpMT4wPAYD +VQQDDDVta2NlcnQgbnljaG9sYXNATnljaG9sYXMtT2xpdmVpcmEgKE55Y2hvbGFz +IE9saXZlaXJhKTAeFw0yNDEwMjgxNzM4NDJaFw0yNzAxMjgxNzM4NDJaMGIxJzAl +BgNVBAoTHm1rY2VydCBkZXZlbG9wbWVudCBjZXJ0aWZpY2F0ZTE3MDUGA1UECwwu +bnljaG9sYXNATnljaG9sYXMtT2xpdmVpcmEgKE55Y2hvbGFzIE9saXZlaXJhKTCC +ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJt+LQ61QFjCp6UtnpYY0xzv ++7XjdQBup5bR9gvCebuuB1H4k4wMK+s75xCGW4CAuyUJ+I5jUZyscPhbR3XAF27g +5G4qmFKIyXgyaPQNK4d3GdtQJL2YHz5lOA4IL7ZjgK0OPb7QjFTADubIYowgQMhS +hUXvDI6R+B3LvDoPeHOdOaKiSMqaBO8i8nYugE/iVi030NxtT6vkh4E6f9Q+JtOc +UCKEpmiq+45dzzwNZB1K72wcnWL/aq/AFALU5ZpYECA60NprBopIQXKpMzJ1cURO +g7haUHbgFct+NggJq5gJOuCbel0728ziifTDGjbyATqxBC6hxYmLsS4M/dkWwDkC +AwEAAaNvMG0wDgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMB8G +A1UdIwQYMBaAFJq6+58E0FocEvznwxd+OKxSzmLtMCUGA1UdEQQeMByCGiouZmxh +c2stanNvbnJwYy5jZW5vYml0LmVzMA0GCSqGSIb3DQEBCwUAA4IBgQAFiP8RAjEu +RWFtBMyltqK+gUR+631BihPIo2V48ACJy2ng1LLUvzjws+S4tP3wBp1y2GdyO7tG +F4ocjmwYSVdMt2wjkVXIfpKqBA2njLNOtc+8OUgDIX3jUDqTsy82rrtcrzp/rQcE +LRz3r1RTt9Yluh8Kbakp/mtrJcS9bER2UP1dl/XNi1R575cdMX0GAVPDShPMQCeD +YaNuHMIUfCZVPbyM5Rh1McrEe/PwaFWnp6wq68fTc1bhaJeeEAZKSDbfPyNk+YTY +f4X0Uw4jsteeJDi5mN9qrurT9KiOpf0JBYsEi2DMNWGVXuRtgJd+7f3IXO7Izorw +/x7rsoxDEh/l1/A6mTb38lyNVBNbslVKOInlHDcTx83wCnTw7OJcg3zaCiLNLvs6 +OR8C/u/m6xmrf+7PydAF+OTnheWG6pyaJ0Yeo5YdZYYfxFOERWMbKoNP9eRzV2b8 +xrce/oGrlZOWzVOW2oULoMnxWshC1Q/6jmwhBMbajJkWQXRevz9QdNc= -----END CERTIFICATE----- diff --git a/.docker/nginx/ssl/flask-jsonrpc.cenobit.es.key b/.docker/nginx/ssl/flask-jsonrpc.cenobit.es.key index 2e2baee0..843ea10c 100644 --- a/.docker/nginx/ssl/flask-jsonrpc.cenobit.es.key +++ b/.docker/nginx/ssl/flask-jsonrpc.cenobit.es.key @@ -1,28 +1,28 @@ -----BEGIN PRIVATE KEY----- -MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDAzjlsHOl7sq9G -pCdeNcP9NYKfjBm6SZQviJ/xDJTFQEsQuJ9t85oWSsLI0SgAXO+o2XndrgjCE+u5 -CEiRzHnTAb/OHQH4Sp3JizbXnDcRZHOp1ZJPb9XlZiQ7tGFFkm5Dah1Vh5eV1wTf -QejfiA4XoN5DY67HkAy1+TRQ7TTXu1C7cG5DMEfQ7FJaDNKHwQjPtiEoGCoWH0iR -y2Z8P0mu0plx8Yuqo5JKWox9vuFEnndhN7kat4I3QN6FEnOZSltKx9iUImew8iTW -iTgMEYguqWiZqVc2lxE0U7gyQueMFGraPIJT23oSj5W6eCxfQ93NKFwF4AmqCm/H -mh344TIjAgMBAAECggEAPpbzxyf/l7ti8QC7EvdLP3jOhbK6a2IVa/TjTyIxbXM4 -qx30C4KII/JzFzPm029YEa0/xXoIt9A1tqR8wm/poFkyBdwe1paKcHH0BLxemBPP -BHQjggwhGM42tvcQmMA3cUUSbsCMsgTSPROtYFtPj9YWMTQ7+ebGaZCTri3C+MDy -uSJwB+muzvBXl+OFP3ltfdc+wRvOde4BtSrtmuY25Y8EYU7ZVqkMZ4YNKTWsvptK -r+nmkxxTapoCRLs0N2did0QhKgaKDv7A8Shg+LEX3NURjyfsRWFkzTn4F470S9GY -eXVTPM5kVSbaFv4SLDDTaonzYTY4D0mfJ01PYRZ78QKBgQDSg8/pzviw7xnkpomX -/RQ44o9RLy8MAvbDdhmCJrTV6OKAf6+PY412KvVdqXjqCImLE7+N/7o54zm1Dr1s -52qBEw835EfFbxgjWgvizYVjinNvcF0kBHlhMNAvAVElwh7mSVCj+hW3/tOIwEiD -fy8FELig6/PrEkCKyXguzoRrCwKBgQDqdtulJKWiNapaQNZyU4g7i6DLd74hDNXb -uArzPrdyGnVBGKC/sqr66r7tmH7uGSyBBt2EAR6t9YEhB8GQRY8c+vvmAbGBHLhm -DvAsZ1/RvFNaWS7MaDU4wpxwr5P8gArQAwH6Blc/C1VY0pJpZgszcp9JLw6UFo8h -7aE/BeeESQKBgAzx6hEYlxZaPZ9f1kowV9P/qox1eFEmxYHTe1ITiz80KOl58JPC -rWo8zgXKbBMIH10cA8V5mjwBCwVlOKS/vj7dqyvmr7smT7esy9pQhQlP9DVAFZon -ZEdjtwmM7S6DN1811u2ebUN5OgzItizP31ZSZugOWlt0Ld/a77cBIAdtAoGAS+fY -6hj4KpCXKwrsoiya4TQuBVEp04dVQQHPQbqfJyibp0xy1jzgj8UptL/279xkhQ0U -7+Rw6z6c5HWfW7a5Q1eZ73fC4JrMs/XIGYgKBFuk3I4c0X43JL4UaYLeNRO67Gbc -3/Xnjvh7U/sv/UFVBP3pxYafgJ/hDMnitwWRU0kCgYBm8HzY5Qk7C5kIS4Z8Luym -DDWpBZMviet/PW4lY8kGEBQf0ImtOC24HisncyWOUFnueNeGVXuMu0gXSMT891Z9 -MoboHEBEhBhhlzQN79RCZIrFS0n3Xt86oeOIzZyJJjCyenKEgjy92WjRpRSBNCyC -VxdcxQ1rL411Gba/sr+sEw== +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCbfi0OtUBYwqel +LZ6WGNMc7/u143UAbqeW0fYLwnm7rgdR+JOMDCvrO+cQhluAgLslCfiOY1GcrHD4 +W0d1wBdu4ORuKphSiMl4Mmj0DSuHdxnbUCS9mB8+ZTgOCC+2Y4CtDj2+0IxUwA7m +yGKMIEDIUoVF7wyOkfgdy7w6D3hznTmiokjKmgTvIvJ2LoBP4lYtN9DcbU+r5IeB +On/UPibTnFAihKZoqvuOXc88DWQdSu9sHJ1i/2qvwBQC1OWaWBAgOtDaawaKSEFy +qTMydXFEToO4WlB24BXLfjYICauYCTrgm3pdO9vM4on0wxo28gE6sQQuocWJi7Eu +DP3ZFsA5AgMBAAECggEBAIgHzHdPjemY8X0J+fEp6pcoQ7cONG+aXrcNHAmRq1nL +l8/YfYxJoap7j1vfjvFOrWpr3K0EqoM8QOClSlDzKgzwZicS0Itz631h7HuFgg1S +3qYE6P+Ms9isSGvOMZ+IUSwUh2QFop8KHn870yrvlH0r5DgYN44KmVvHvvW0TxYk +ms9ewmo89xJrF/4sFsVRZSDcHsdbtSi1NttGDq1PV+AZl5bcd3lI4UnON2lQvbRR +ZT06Ln9++CH18Rvq4pdxCCX5Xlp5IGw1DEgSeg25BGLhYOT73hAsmHsaUbQmeGmP +22NqYMqvZGWoX/JM3beIJuIA3P62ed+56flWOWDaogECgYEAwCl1C+aYeNFi7xbp +ThLh5qrAyLBc+nI4ng2KoAb1m5qQggf5WpRtjQQ2s23+cZE0Bd94eqTNNV8n7Fza +WfWMRn3LaZQPypeZnxZbSRvhtbXyV8CXP+r/hHNA800rWjNffvKZw/8ClOr4DDG1 +35qUkFNKLpw2j2EtN379QjVaACECgYEAzyYsT2Fn2p1jPHmD/RtOveyIDeqhbWW0 +TuVfVWfT/c7MPego+6DxmDaohMNHb1UFe5Aphrnin+aTVjXusWPSddRmwfFxLjCL +JMn9X33gQ2tmTvaf234IVvFnwWtqknCsP4b/qpx72J4US4JSja+ec1UMBPK7i1vx +BSPbk+wpHRkCgYAA2O30NHsdpUa7aS10M68dVkltWSG/1Psc71BaLk2s9vyGJkTa +OmdVRNx85BJIK+y/zeF5IPrqrlW9vBcWaHfK48ke2RFMwmhiBDY410+ifn/oFShV +ltKzodJ6gC+dwTY9mBJHg/td7r9TRnDX4JWhIUzlcbjn3vTVFtFFFnkkIQKBgQCh +XW8u9wXburbPKSm0RU11jYf+r0PI4G0w+GTnNtZg5HMd1nf2bE1G8W0c3rIK/AQl +WQvAM5ViUk973M9vx/7uYuMZOWedP5tksSR6+qd0XXl5EY09glQkxjC472A5ayQC +6LrIwNWIzLuqhn6geKnJ1fpi8GNAEAMUqfYmqNTuIQKBgDPoAZe7kkEW2pfK/Stg +UZMxLJmTk2c0HOL6FBOaqTvlSafKXzR31u80I2HVXaAvj8qhOrtc56DJqZYNUuYb +ubh2Ns+1lcnI/Kwlp7OT5HQu2IzVMZYxV7iqPcKXv0wiYp1h0GM4540uCukn0CeX +mm+zyaxItbYXYt1f2ttR5CKz -----END PRIVATE KEY----- diff --git a/.github/workflows/on_update.yml b/.github/workflows/on_update.yml index dc30b886..6b4896a7 100644 --- a/.github/workflows/on_update.yml +++ b/.github/workflows/on_update.yml @@ -39,7 +39,7 @@ jobs: - name: Run tox (Style, Type checker, Security, Docs) | Python 3.12 if: ${{ matrix.platform == 'ubuntu-latest' && matrix.python-version == '3.12' }} run: | - tox -e style,typing-mypy,typing-pyright,security-safety,security-bandit,docs -p all + tox -e style,typing-mypy,typing-pyright,security-bandit,docs -p all - name: Run tox (Tests) | ${{ matrix.platform }} run: | tox -e py,py-async diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index 5c9c990a..e20ab036 100644 --- a/.github/workflows/pre_release.yml +++ b/.github/workflows/pre_release.yml @@ -155,6 +155,14 @@ jobs: steps: - name: Checkout source at ${{ matrix.platform }} uses: actions/checkout@v4 + - name: Setup Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Prepare build + run: | + python -m pip install tomlkit + python bin/cibw-before-build.py - name: Build wheels uses: pypa/cibuildwheel@v2.21.2 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ae690d4d..aea8b401 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,10 +57,17 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + - name: Prepare build + run: | + python -m pip install tomlkit + python bin/cibw-before-build.py - name: Install cbuild dependencies run: python -m pip install -r requirements/cbuild.txt - name: Build wheels - run: python -m cibuildwheel --output-dir wheelhouse + uses: pypa/cibuildwheel@v2.21.2 + with: + package-dir: . + output-dir: wheelhouse - uses: actions/upload-artifact@v4 with: name: release-cibw-wheels-${{ matrix.platform }}-${{ matrix.python-version }} diff --git a/.gitignore b/.gitignore index 9fb3e985..7f69e002 100644 --- a/.gitignore +++ b/.gitignore @@ -89,6 +89,7 @@ parts/ sdist/ var/ wheels/ +wheelhouse/ share/python-wheels/ *.egg-info/ .installed.cfg diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 99b1890a..bc2071b7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,5 +1,5 @@ exclude: '.git|.tox|.venv|.vscode|__pycache__|.pytest_cache|.eggs|.mypy_cache|.pytype|htmlcov|junit|htmldoc' -default_stages: [commit] +default_stages: [pre-commit] fail_fast: true default_language_version: python: python3.12 diff --git a/Dockerfile.it b/Dockerfile.it index 49e15500..a660a269 100644 --- a/Dockerfile.it +++ b/Dockerfile.it @@ -1,4 +1,4 @@ -FROM python:3.12-alpine as builder +FROM python:3.12-alpine AS builder RUN apk add --no-cache --update --virtual .build-deps \ build-base \ @@ -16,7 +16,7 @@ WORKDIR /svc COPY requirements/tests.txt /svc/ RUN pip install pip setuptools wheel --upgrade \ && pip wheel --wheel-dir=/svc/wheels -r tests.txt \ - poetry-core>=1.0.0 + && pip install poetry-core>=1.0.0 FROM python:3.12-alpine @@ -57,6 +57,7 @@ ARG VERSION=1 RUN echo "Version: ${VERSION}" COPY .docker/* requirements/tests.txt tests/integration/*.py tests/integration/*.ini /app/ +COPY tests/integration/shared/*.py tests/shared/ /app/shared/ RUN pip install pip setuptools wheel --upgrade \ && pip install --no-index --find-links=/svc/wheels -r tests.txt \ @@ -74,4 +75,4 @@ RUN pip install pip setuptools wheel --upgrade \ USER flask_user -CMD ./wait-for.sh ${SITE_DOMAIN}:${SITE_PORT} -t 600 -- pytest --junitxml=test-results/junit.xml +CMD ./wait-for.sh ${SITE_DOMAIN}:${SITE_PORT} -t 600 -- pytest -n auto --junitxml=test-results/junit.xml diff --git a/Dockerfile.local b/Dockerfile.local index b7f5157e..434e7f4d 100644 --- a/Dockerfile.local +++ b/Dockerfile.local @@ -1,4 +1,4 @@ -FROM python:3.12-alpine as builder +FROM python:3.12-alpine AS builder RUN apk add --no-cache --update --virtual .build-deps \ build-base \ @@ -77,6 +77,4 @@ USER flask_user ARG VERSION=1 RUN echo "Version: ${VERSION}" -COPY .docker/* /app/ -COPY tests/test_apps/app/__init__.py /app/app.py -COPY tests/test_apps/async_app/__init__.py /app/async_app.py +COPY .docker/* tests/test_apps/ /app/ diff --git a/Dockerfile.py310.test b/Dockerfile.py310.test index 946bc65e..6f2a9795 100644 --- a/Dockerfile.py310.test +++ b/Dockerfile.py310.test @@ -18,7 +18,7 @@ RUN set -ex \ && pip install -r requirements/base.txt \ && pip install -r requirements/style.txt \ && pip install -r requirements/tests.txt \ - poetry-core>=1.0.0 \ + && pip install poetry-core>=1.0.0 \ && apk del .build-deps \ && addgroup -S kuchulu \ && adduser \ diff --git a/Dockerfile.py311.test b/Dockerfile.py311.test index 26e924e7..d2fc6304 100644 --- a/Dockerfile.py311.test +++ b/Dockerfile.py311.test @@ -18,7 +18,7 @@ RUN set -ex \ && pip install -r requirements/base.txt \ && pip install -r requirements/style.txt \ && pip install -r requirements/tests.txt \ - poetry-core>=1.0.0 \ + && pip install poetry-core>=1.0.0 \ && apk del .build-deps \ && addgroup -S kuchulu \ && adduser \ diff --git a/Dockerfile.py312.test b/Dockerfile.py312.test index 59c2560f..10089fe6 100644 --- a/Dockerfile.py312.test +++ b/Dockerfile.py312.test @@ -18,7 +18,7 @@ RUN set -ex \ && pip install -r requirements/base.txt \ && pip install -r requirements/style.txt \ && pip install -r requirements/tests.txt \ - poetry-core>=1.0.0 \ + && pip install poetry-core>=1.0.0 \ && apk del .build-deps \ && addgroup -S kuchulu \ && adduser \ diff --git a/Dockerfile.py39.test b/Dockerfile.py39.test index b72d52fa..9a9a3fd8 100644 --- a/Dockerfile.py39.test +++ b/Dockerfile.py39.test @@ -18,7 +18,7 @@ RUN set -ex \ && pip install -r requirements/base.txt \ && pip install -r requirements/style.txt \ && pip install -r requirements/tests.txt \ - poetry-core>=1.0.0 \ + && pip install poetry-core>=1.0.0 \ && apk del .build-deps \ && addgroup -S kuchulu \ && adduser \ diff --git a/MANIFEST.in b/MANIFEST.in index 97f58dd2..920b3b33 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -11,4 +11,4 @@ graft docs prune docs/_build graft examples graft tests -global-exclude *~ *.py[cod] *.so *.swp *.editorconfig __pycache__ +global-exclude *~ *.py[cod] *.so *.swp *.editorconfig __pycache__ .tox **/.tox venv .venv .venv.* **/venv **/.venv **/.venv.* diff --git a/Makefile b/Makefile index 5d53edc4..d74631d3 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ all: clean test clean: @find {src,examples,tests} -regex ".*\.\(so\|pyc\)" | xargs rm -rf - @find {src,examples,tests} -name "__pycache__" -o -name ".coverage" -o -name ".tox" -o -name ".pytest_cache" -o -name ".ruff_cache" -o -name ".pkg" -o -name ".tmp" | xargs rm -rf + @find {src,examples,tests} -name "__pycache__" -o -name ".coverage" -o -name "junit" -o -name "coverage.lcov" -o -name "htmlcov" -o -name ".tox" -o -name ".pytest_cache" -o -name ".ruff_cache" -o -name ".pkg" -o -name ".tmp" | xargs rm -rf @rm -rf .coverage coverage.* .eggs/ .mypy_cache/ .pytype/ .ruff_cache/ .pytest_cache/ .tox/ src/*.egg-info/ htmlcov/ junit/ htmldoc/ build/ dist/ wheelhouse/ style: @@ -33,6 +33,7 @@ release: test @python -m pip install --upgrade -r requirements/cbuild.txt @python -m build @MYPYC_ENABLE=1 python setup.py bdist_wheel + @cibuildwheel publish-test: release @python -m pip install --upgrade twine diff --git a/bin/cibw-before-build.py b/bin/cibw-before-build.py new file mode 100755 index 00000000..4cd31a92 --- /dev/null +++ b/bin/cibw-before-build.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python +# Copyright (c) 2024-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the Cenobit Technologies nor the names of +# its contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +from tomlkit import dumps +import tomllib + +with open('pyproject.toml', 'rb') as fp: + pyproject = tomllib.load(fp) + +pyproject['build-system']['requires'] = ['setuptools>=61.0', 'wheel>=0.42', 'mypy', 'pydantic'] +pyproject['build-system']['build-backend'] = 'setuptools.build_meta' + +with open('pyproject.toml', 'w') as fp: + fp.write(dumps(pyproject)) diff --git a/docker-compose.it.yml b/docker-compose.it.yml index 049f2878..1b007dc5 100644 --- a/docker-compose.it.yml +++ b/docker-compose.it.yml @@ -15,7 +15,8 @@ services: - .pytest_cache/test-results/app:/app/test-results - .pytest_cache/screnshots/app:/app/.pytest_cache/screnshots depends_on: - - nginx + nginx: + condition: service_healthy networks: - default @@ -27,11 +28,17 @@ services: - FLASK_SERVER_NAME=app:5000 user: ${UID:-0}:${GID:-0} command: > - python app.py + python -m app ports: - '5000:5000' networks: - default + healthcheck: + test: wget -qO - app:5000/health || exit 1 + interval: 5s + timeout: 3s + retries: 5 + start_period: 5s async-sut: build: @@ -45,11 +52,14 @@ services: - API_URL=https://async-app.flask-jsonrpc.cenobit.es/api - BROWSABLE_API_URL=https://async-app.flask-jsonrpc.cenobit.es/api/browse user: ${UID:-0}:${GID:-0} + command: > + ./wait-for.sh async-app.flask-jsonrpc.cenobit.es:80 -t 600 -- pytest -n auto --junitxml=test-results/junit.xml test_async_app.py volumes: - .pytest_cache/test-results/async-app:/app/test-results - .pytest_cache/screnshots/async-app:/app/.pytest_cache/screnshots depends_on: - - nginx + nginx: + condition: service_healthy networks: - default @@ -63,11 +73,17 @@ services: - FLASK_SERVER_NAME=async-app:5000 user: ${UID:-0}:${GID:-0} command: > - python async-app.py + python -m async_app ports: - '5001:5000' networks: - default + healthcheck: + test: wget -qO - async-app:5000/health || exit 1 + interval: 5s + timeout: 3s + retries: 5 + start_period: 5s mypyc-sut: build: @@ -85,7 +101,8 @@ services: - .pytest_cache/test-results/app:/app/test-results - .pytest_cache/screnshots/app:/app/.pytest_cache/screnshots depends_on: - - nginx + nginx: + condition: service_healthy networks: - default @@ -99,11 +116,17 @@ services: - FLASK_SERVER_NAME=mypyc-app:5000 user: ${UID:-0}:${GID:-0} command: > - python app.py + python -m app ports: - '5002:5000' networks: - default + healthcheck: + test: wget -qO - mypyc-app:5000/health || exit 1 + interval: 5s + timeout: 3s + retries: 5 + start_period: 5s nginx: image: nginx:mainline-alpine @@ -115,15 +138,24 @@ services: - .docker/nginx/ssl:/etc/nginx/ssl - .docker/nginx/nginx.conf:/etc/nginx/nginx.conf depends_on: - - app - - async-app - - mypyc-app + app: + condition: service_healthy + async-app: + condition: service_healthy + mypyc-app: + condition: service_healthy networks: default: aliases: - app.flask-jsonrpc.cenobit.es - async-app.flask-jsonrpc.cenobit.es - mypyc-app.flask-jsonrpc.cenobit.es + healthcheck: + test: wget -qO - nginx:80/health || exit 1 + interval: 5s + timeout: 3s + retries: 5 + start_period: 5s networks: default: diff --git a/examples/javascript/tox.ini b/examples/javascript/tox.ini index ea6d3675..04d38d5f 100644 --- a/examples/javascript/tox.ini +++ b/examples/javascript/tox.ini @@ -11,7 +11,11 @@ envtmpdir = {toxworkdir}/tmp/{envname} constrain_package_deps = true use_frozen_constraints = true deps = - pytest + pytest==8.3.3 + pytest-cov==5.0.0 + pytest-xdist==3.6.1 + pytest-sugar==1.0.0 + pytest-env==1.1.5 async: Flask[async]>=3.0.0,<4.0 commands = - pytest -vv --tb=short --basetemp={envtmpdir} {posargs} + pytest -n auto -vv --tb=short --basetemp={envtmpdir} {posargs} diff --git a/examples/minimal-async/src/minimal_async/app.py b/examples/minimal-async/src/minimal_async/app.py index 0cfb5a35..c19ecadf 100644 --- a/examples/minimal-async/src/minimal_async/app.py +++ b/examples/minimal-async/src/minimal_async/app.py @@ -63,7 +63,7 @@ class MyException(Exception): @jsonrpc.errorhandler(MyException) -async def handle_my_exception(ex: MyException) -> t.Dict[str, t.Any]: +async def handle_my_exception(ex: MyException) -> dict[str, t.Any]: await asyncio.sleep(0) return {'message': 'It is a custom exception', 'code': '0001'} @@ -87,7 +87,7 @@ async def hello_default_args(string: str = 'Flask JSON-RPC') -> str: @jsonrpc.method('App.argsValidate') -async def args_validate(a1: int, a2: str, a3: bool, a4: t.List[t.Any], a5: t.Dict[t.Any, t.Any]) -> str: +async def args_validate(a1: int, a2: str, a3: bool, a4: list[t.Any], a5: dict[t.Any, t.Any]) -> str: await asyncio.sleep(0) return f'Number: {a1}, String: {a2}, Boolean: {a3}, Array: {a4}, Object: {a5}' @@ -150,6 +150,6 @@ async def one_decorator() -> str: @jsonrpc.method('App.multiDecorators') @check_terminal_id @jsonrpc_headers -async def multi_decorators() -> t.Dict[str, t.Any]: +async def multi_decorators() -> dict[str, t.Any]: await asyncio.sleep(0) return {'terminal_id': request.get_json(silent=True).get('terminal_id', 0), 'headers': dict(request.headers)} diff --git a/examples/minimal-async/tests/test_app.py b/examples/minimal-async/tests/test_app.py index 3773c1b2..8806de31 100644 --- a/examples/minimal-async/tests/test_app.py +++ b/examples/minimal-async/tests/test_app.py @@ -174,7 +174,7 @@ def test_multi_decorators(client: 'FlaskClient') -> None: 'Host': 'localhost', 'Content-Type': 'application/json', 'Content-Length': '78', - } + }, }, } assert rv.headers == Headers( diff --git a/examples/minimal-async/tests/utils.py b/examples/minimal-async/tests/utils.py index 3ca33dcb..4966d0cf 100644 --- a/examples/minimal-async/tests/utils.py +++ b/examples/minimal-async/tests/utils.py @@ -1,13 +1,16 @@ from typing import Any +# Added in version 3.11. +from typing_extensions import Self + class EqMock: value: Any = None - def __init__(self, remember: bool = False) -> None: + def __init__(self: Self, remember: bool = False) -> None: self.remember: bool = remember - def __eq__(self, other: Any) -> bool: # noqa: ANN401 + def __eq__(self: Self, other: Any) -> bool: # noqa: ANN401 if self.remember and self.value is not None: return self.value == other else: @@ -18,8 +21,8 @@ def __eq__(self, other: Any) -> bool: # noqa: ANN401 return True - def __repr__(self) -> str: + def __repr__(self: Self) -> str: return repr(self.value) if self.remember else super().__repr__() - def __str__(self) -> str: + def __str__(self: Self) -> str: return str(self.value) if self.remember else super().__str__() diff --git a/examples/minimal-async/tox.ini b/examples/minimal-async/tox.ini index ea6d3675..04d38d5f 100644 --- a/examples/minimal-async/tox.ini +++ b/examples/minimal-async/tox.ini @@ -11,7 +11,11 @@ envtmpdir = {toxworkdir}/tmp/{envname} constrain_package_deps = true use_frozen_constraints = true deps = - pytest + pytest==8.3.3 + pytest-cov==5.0.0 + pytest-xdist==3.6.1 + pytest-sugar==1.0.0 + pytest-env==1.1.5 async: Flask[async]>=3.0.0,<4.0 commands = - pytest -vv --tb=short --basetemp={envtmpdir} {posargs} + pytest -n auto -vv --tb=short --basetemp={envtmpdir} {posargs} diff --git a/examples/minimal/src/minimal/app.py b/examples/minimal/src/minimal/app.py index 503b9544..a55e0eee 100644 --- a/examples/minimal/src/minimal/app.py +++ b/examples/minimal/src/minimal/app.py @@ -60,7 +60,7 @@ class MyException(Exception): @jsonrpc.errorhandler(MyException) -def handle_my_exception(ex: MyException) -> t.Dict[str, t.Any]: +def handle_my_exception(ex: MyException) -> dict[str, t.Any]: return {'message': 'It is a custom exception', 'code': '0001'} @@ -80,7 +80,7 @@ def hello_default_args(string: str = 'Flask JSON-RPC') -> str: @jsonrpc.method('App.argsValidate') -def args_validate(a1: int, a2: str, a3: bool, a4: t.List[t.Any], a5: t.Dict[t.Any, t.Any]) -> str: +def args_validate(a1: int, a2: str, a3: bool, a4: list[t.Any], a5: dict[t.Any, t.Any]) -> str: return f'Number: {a1}, String: {a2}, Boolean: {a3}, Array: {a4}, Object: {a5}' @@ -134,5 +134,5 @@ def one_decorator() -> str: @jsonrpc.method('App.multiDecorators') @check_terminal_id @jsonrpc_headers -def multi_decorators() -> t.Dict[str, t.Any]: +def multi_decorators() -> dict[str, t.Any]: return {'terminal_id': request.get_json(silent=True).get('terminal_id', 0), 'headers': dict(request.headers)} diff --git a/examples/minimal/tests/test_app.py b/examples/minimal/tests/test_app.py index c2ea1658..8806de31 100644 --- a/examples/minimal/tests/test_app.py +++ b/examples/minimal/tests/test_app.py @@ -25,6 +25,7 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import typing as t + from werkzeug.datastructures import Headers from tests.utils import EqMock @@ -88,7 +89,7 @@ def test_not_notify(client: 'FlaskClient') -> None: 'code': -32600, 'data': { 'message': "The method 'App.notNotify' doesn't allow Notification Request " - "object (without an 'id' member)" + "object (without an 'id' member)" }, 'message': 'Invalid Request', 'name': 'InvalidRequestError', @@ -173,7 +174,7 @@ def test_multi_decorators(client: 'FlaskClient') -> None: 'Host': 'localhost', 'Content-Type': 'application/json', 'Content-Length': '78', - } + }, }, } assert rv.headers == Headers( diff --git a/examples/minimal/tests/utils.py b/examples/minimal/tests/utils.py index 3ca33dcb..4966d0cf 100644 --- a/examples/minimal/tests/utils.py +++ b/examples/minimal/tests/utils.py @@ -1,13 +1,16 @@ from typing import Any +# Added in version 3.11. +from typing_extensions import Self + class EqMock: value: Any = None - def __init__(self, remember: bool = False) -> None: + def __init__(self: Self, remember: bool = False) -> None: self.remember: bool = remember - def __eq__(self, other: Any) -> bool: # noqa: ANN401 + def __eq__(self: Self, other: Any) -> bool: # noqa: ANN401 if self.remember and self.value is not None: return self.value == other else: @@ -18,8 +21,8 @@ def __eq__(self, other: Any) -> bool: # noqa: ANN401 return True - def __repr__(self) -> str: + def __repr__(self: Self) -> str: return repr(self.value) if self.remember else super().__repr__() - def __str__(self) -> str: + def __str__(self: Self) -> str: return str(self.value) if self.remember else super().__str__() diff --git a/examples/minimal/tox.ini b/examples/minimal/tox.ini index ea6d3675..04d38d5f 100644 --- a/examples/minimal/tox.ini +++ b/examples/minimal/tox.ini @@ -11,7 +11,11 @@ envtmpdir = {toxworkdir}/tmp/{envname} constrain_package_deps = true use_frozen_constraints = true deps = - pytest + pytest==8.3.3 + pytest-cov==5.0.0 + pytest-xdist==3.6.1 + pytest-sugar==1.0.0 + pytest-env==1.1.5 async: Flask[async]>=3.0.0,<4.0 commands = - pytest -vv --tb=short --basetemp={envtmpdir} {posargs} + pytest -n auto -vv --tb=short --basetemp={envtmpdir} {posargs} diff --git a/examples/modular/src/modular/api/article.py b/examples/modular/src/modular/api/article.py index fc6fb2f8..c9f8645b 100644 --- a/examples/modular/src/modular/api/article.py +++ b/examples/modular/src/modular/api/article.py @@ -53,7 +53,7 @@ class Article: @article.errorhandler(ArticleNotFoundException) -def handle_user_not_found_exception(ex: ArticleNotFoundException) -> t.Dict[str, t.Any]: +def handle_user_not_found_exception(ex: ArticleNotFoundException) -> dict[str, t.Any]: return {'message': f'Article {ex.article_id} not found', 'code': '2001'} diff --git a/examples/modular/src/modular/api/user.py b/examples/modular/src/modular/api/user.py index 124f0af6..f24ffc42 100644 --- a/examples/modular/src/modular/api/user.py +++ b/examples/modular/src/modular/api/user.py @@ -51,7 +51,7 @@ def __init__(self: Self, id: int, name: str) -> None: self.name = name -def handle_user_not_found_exception(ex: UserNotFoundException) -> t.Dict[str, t.Any]: +def handle_user_not_found_exception(ex: UserNotFoundException) -> dict[str, t.Any]: return {'message': f'User {ex.user_id} not found', 'code': '1001'} diff --git a/examples/modular/src/modular/app.py b/examples/modular/src/modular/app.py index 3eb7c060..502fb3b3 100644 --- a/examples/modular/src/modular/app.py +++ b/examples/modular/src/modular/app.py @@ -41,7 +41,7 @@ jsonrpc.errorhandler(ValueError) -def handle_value_error_exception(ex: ValueError) -> t.Dict[str, t.Any]: +def handle_value_error_exception(ex: ValueError) -> dict[str, t.Any]: return {'message': 'Generic global error handler does not work, :(', 'code': '0000'} diff --git a/examples/modular/tox.ini b/examples/modular/tox.ini index ea6d3675..04d38d5f 100644 --- a/examples/modular/tox.ini +++ b/examples/modular/tox.ini @@ -11,7 +11,11 @@ envtmpdir = {toxworkdir}/tmp/{envname} constrain_package_deps = true use_frozen_constraints = true deps = - pytest + pytest==8.3.3 + pytest-cov==5.0.0 + pytest-xdist==3.6.1 + pytest-sugar==1.0.0 + pytest-env==1.1.5 async: Flask[async]>=3.0.0,<4.0 commands = - pytest -vv --tb=short --basetemp={envtmpdir} {posargs} + pytest -n auto -vv --tb=short --basetemp={envtmpdir} {posargs} diff --git a/examples/multiplesite/tests/test_app.py b/examples/multiplesite/tests/test_app.py index 77465c8e..b43432cd 100644 --- a/examples/multiplesite/tests/test_app.py +++ b/examples/multiplesite/tests/test_app.py @@ -26,6 +26,9 @@ # POSSIBILITY OF SUCH DAMAGE. import typing as t +import pytest +from multiplesite.app import UnauthorizedError + if t.TYPE_CHECKING: from flask.testing import FlaskClient @@ -46,6 +49,15 @@ def test_index_v2(client: 'FlaskClient') -> None: assert rv.status_code == 200 +def test_index_v2_with_invalid_auth(client: 'FlaskClient') -> None: + with pytest.raises(UnauthorizedError): + client.post( + '/api/v2', + json={'id': 1, 'jsonrpc': '2.0', 'method': 'App.index'}, + headers={'X-Username': 'username', 'X-Password': 'invalid'}, + ) + + def test_rpc_describe_v1(client: 'FlaskClient') -> None: rv = client.post('/api/v1', json={'id': 1, 'jsonrpc': '2.0', 'method': 'rpc.describe'}) data = rv.get_json() diff --git a/examples/multiplesite/tox.ini b/examples/multiplesite/tox.ini index ea6d3675..04d38d5f 100644 --- a/examples/multiplesite/tox.ini +++ b/examples/multiplesite/tox.ini @@ -11,7 +11,11 @@ envtmpdir = {toxworkdir}/tmp/{envname} constrain_package_deps = true use_frozen_constraints = true deps = - pytest + pytest==8.3.3 + pytest-cov==5.0.0 + pytest-xdist==3.6.1 + pytest-sugar==1.0.0 + pytest-env==1.1.5 async: Flask[async]>=3.0.0,<4.0 commands = - pytest -vv --tb=short --basetemp={envtmpdir} {posargs} + pytest -n auto -vv --tb=short --basetemp={envtmpdir} {posargs} diff --git a/examples/openrpc/src/petstore/app.py b/examples/openrpc/src/petstore/app.py index d3f57536..c552f862 100644 --- a/examples/openrpc/src/petstore/app.py +++ b/examples/openrpc/src/petstore/app.py @@ -121,7 +121,7 @@ class Pet(NewPet): ), ) @jsonrpc.method('Petstore.get_pets') -def get_pets(tags: t.Optional[t.List[str]] = None, limit: t.Optional[int] = None) -> t.List[Pet]: +def get_pets(tags: t.Optional[list[str]] = None, limit: t.Optional[int] = None) -> list[Pet]: pets = PETS if tags is not None: pets = [pet for pet in pets if pet.tag in tags] diff --git a/examples/openrpc/tox.ini b/examples/openrpc/tox.ini index ea6d3675..04d38d5f 100644 --- a/examples/openrpc/tox.ini +++ b/examples/openrpc/tox.ini @@ -11,7 +11,11 @@ envtmpdir = {toxworkdir}/tmp/{envname} constrain_package_deps = true use_frozen_constraints = true deps = - pytest + pytest==8.3.3 + pytest-cov==5.0.0 + pytest-xdist==3.6.1 + pytest-sugar==1.0.0 + pytest-env==1.1.5 async: Flask[async]>=3.0.0,<4.0 commands = - pytest -vv --tb=short --basetemp={envtmpdir} {posargs} + pytest -n auto -vv --tb=short --basetemp={envtmpdir} {posargs} diff --git a/pyproject.toml b/pyproject.toml index aa85ee60..7d15e6bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "Flask-JSONRPC" -version = "4.0.0a3" +version = "4.0.0a5" description = "Adds JSONRPC support to Flask." readme = {file = "README.md", content-type = "text/markdown"} license = {file = "LICENSE.txt"} @@ -60,6 +60,17 @@ include = [ "tox.ini", ] exclude = [ + ".tox/", + "venv/", + ".venv/", + ".venv.*/", + "**/venv/", + "**/.venv/", + "**/.venv.*", + "**/.tox/", + "**/junit/", + "**/htmlcov/", + "**/coverage.*", "docs/_build/", ] @@ -71,9 +82,6 @@ include-package-data = true where = ["src"] namespaces = false -[tool.distutils.bdist_wheel] -universal = true - [tool.cibuildwheel] before-all = "uname -a" build-verbosity = 1 @@ -84,6 +92,15 @@ skip = [ "*-win32", "pp*", ] +test-requires = [ + "pytest", + "pytest-cov", + "pytest-xdist", + "pytest-sugar", + "pytest-env", + "requests" +] +test-command = "pytest -n auto -vv --tb=short --rootdir={project} {project}/tests --cov-fail-under=0" [tool.cibuildwheel.environment] MYPYC_ENABLE = "1" @@ -120,7 +137,7 @@ inline-quotes = "single" docstring-quotes = "double" [tool.ruff.lint.flake8-type-checking] -exempt-modules = ["typing", "typing_extensions"] +exempt-modules = ["typing", "typing_extensions", "annotated_types"] [tool.ruff.lint.flake8-bandit] check-typed-exception = true @@ -148,7 +165,7 @@ section-order = [ [tool.ruff.lint.isort.sections] "flask" = ["flask"] "pydantic" = ["pydantic"] -"typing-extensions" = ["typing_extensions"] +"typing-extensions" = ["typing_inspect", "typing_extensions"] [tool.ruff.lint.pydocstyle] convention = "google" @@ -174,6 +191,7 @@ filterwarnings = [ "ignore::pytest.PytestUnraisableExceptionWarning" ] norecursedirs = [ + "tests/shared", "tests/test_apps", "tests/integration", ] @@ -230,12 +248,14 @@ files = ["src/flask_jsonrpc"] python_version = "3.12" pretty = true strict = true -check_untyped_defs = true +show_error_codes = true ignore_errors = false ignore_missing_imports = false -show_error_codes = true -disallow_any_generics = true +warn_redundant_casts = true +warn_unused_ignores = true no_implicit_reexport = true +check_untyped_defs = true +disallow_any_generics = true disallow_untyped_defs = true [[tool.mypy.overrides]] @@ -243,6 +263,7 @@ module = [ "asgiref.*", "mypy-werkzeug.datastructures.*", "typeguard.*", + "typing_inspect.*", "dotenv.*", ] ignore_missing_imports = true diff --git a/requirements/security.txt b/requirements/security.txt index d1c47796..333ffdc5 100644 --- a/requirements/security.txt +++ b/requirements/security.txt @@ -1,4 +1,4 @@ # Security check # ------------------------------------------------------------------------------ bandit;python_version>="3.11" # https://github.com/PyCQA/bandit -safety;python_version>="3.11" # https://github.com/pyupio/safety +# safety;python_version>="3.11" # https://github.com/pyupio/safety diff --git a/setup.py b/setup.py index 90e50cb3..10f38eb5 100755 --- a/setup.py +++ b/setup.py @@ -25,6 +25,8 @@ # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. +from __future__ import annotations + import os import sys import typing as t @@ -32,19 +34,19 @@ import setuptools - -def find_python_files(path: pathlib.Path) -> t.List[pathlib.Path]: - return path.rglob('*.py') - - USE_MYPYC = os.getenv('MYPYC_ENABLE', 'False').lower() in ('true', 't', '1') if len(sys.argv) > 1 and sys.argv[1] == '--use-mypyc': USE_MYPYC = True setup_attrs = {'name': 'Flask-JSONRPC', 'packages': setuptools.find_packages()} + +def find_python_files(path: pathlib.Path) -> t.Generator[pathlib.Path, None, None]: + return path.rglob('*.py') + + if USE_MYPYC: - from mypyc.build import mypycify # pylint: disable=E0611 + from mypyc.build import mypycify project_dir = pathlib.Path(__file__).resolve().parent @@ -56,6 +58,8 @@ def find_python_files(path: pathlib.Path) -> t.List[pathlib.Path]: ext_modules = [ '--config-file', str(project_dir / 'pyproject.toml'), + '--install-types', + '--non-interactive', '--strict', '--check-untyped-defs', '--ignore-missing-imports', @@ -70,7 +74,7 @@ def find_python_files(path: pathlib.Path) -> t.List[pathlib.Path]: ext_modules.extend([str(p) for p in discovered if p.relative_to(project_dir).as_posix() not in blocklist]) opt_level = os.getenv('MYPYC_OPT_LEVEL', '3') - setup_attrs['requires'] = ['mypy'] + setup_attrs['requires'] = ['mypy', 'pydantic'] setup_attrs['ext_modules'] = mypycify(ext_modules, opt_level=opt_level, verbose=True) setup_attrs['package_data'] = {'flask_jsonrpc.contrib.browse': ['static*', 'templates*']} diff --git a/src/flask_jsonrpc/app.py b/src/flask_jsonrpc/app.py index 1f4ffe20..45fac144 100644 --- a/src/flask_jsonrpc/app.py +++ b/src/flask_jsonrpc/app.py @@ -65,15 +65,15 @@ def __init__( if app: self.init_app(app) + def _make_jsonrpc_browse_url(self: Self, path: str) -> str: + return ''.join([path.rstrip('/'), '/browse']) + def get_jsonrpc_site(self: Self) -> JSONRPCSite: return self.jsonrpc_site def get_jsonrpc_site_api(self: Self) -> type[JSONRPCView]: return self.jsonrpc_site_api - def _make_jsonrpc_browse_url(self: Self, path: str) -> str: - return ''.join([path.rstrip('/'), '/browse']) - def init_app(self: Self, app: Flask) -> None: http_host = app.config.get('SERVER_NAME') app_root = app.config['APPLICATION_ROOT'] diff --git a/src/flask_jsonrpc/blueprints.py b/src/flask_jsonrpc/blueprints.py index 06058fd0..45f616d7 100644 --- a/src/flask_jsonrpc/blueprints.py +++ b/src/flask_jsonrpc/blueprints.py @@ -57,3 +57,11 @@ def get_jsonrpc_site(self: Self) -> JSONRPCSite: def get_jsonrpc_site_api(self: Self) -> type[JSONRPCView]: return self.jsonrpc_site_api + + def register( + self: Self, + view_func: t.Callable[..., t.Any], + name: str | None = None, + **options: t.Any, # noqa: ANN401 + ) -> None: + self.register_view_function(view_func, name, **options) diff --git a/src/flask_jsonrpc/contrib/openrpc/methods.py b/src/flask_jsonrpc/contrib/openrpc/methods.py index 6a92e7d8..a2d41060 100644 --- a/src/flask_jsonrpc/contrib/openrpc/methods.py +++ b/src/flask_jsonrpc/contrib/openrpc/methods.py @@ -27,6 +27,7 @@ from __future__ import annotations import typing as t +from functools import lru_cache from collections import OrderedDict from flask_jsonrpc.encoders import serializable @@ -34,12 +35,6 @@ from . import typing as st from .utils import MethodExtendSchema, extend_schema -# Python 3.9+ -try: - from functools import cache -except ImportError: # pragma: no cover - from functools import lru_cache as cache - if t.TYPE_CHECKING: from flask_jsonrpc.site import JSONRPCSite @@ -50,7 +45,7 @@ def _openrpc_discover_method( jsonrpc_sites: list[JSONRPCSite], *, openrpc_schema: st.OpenRPCSchema ) -> t.Callable[..., st.OpenRPCSchema]: - @cache + @lru_cache @extend_schema( name=OPENRPC_DISCOVER_METHOD_NAME, description='Returns an OpenRPC schema as a description of this service', @@ -77,16 +72,19 @@ def cached_openrpc_discover_method() -> st.OpenRPCSchema: ) for name, (method_describe, view_func) in service_describe_methods.items(): - fn_openrpc_method_schema = getattr(view_func, 'openrpc_method_schema', MethodExtendSchema()) # noqa: B010 - method_schema = { + fn_openrpc_method_schema: MethodExtendSchema = t.cast( + MethodExtendSchema, getattr(view_func, 'openrpc_method_schema', MethodExtendSchema()) + ) + method_schema: dict[str, t.Any] = { 'name': fn_openrpc_method_schema.name or name, 'description': fn_openrpc_method_schema.description or method_describe.description, + 'params': [], 'result': { 'name': 'default', 'schema': {'type': st.SchemaDataType.from_rpc_describe_type(method_describe.returns.type)}, }, } - method_params_schema = [] + method_params_schema: list[dict[str, t.Any]] = [] for param in method_describe.params: method_params_schema.append( { @@ -102,7 +100,7 @@ def cached_openrpc_discover_method() -> st.OpenRPCSchema: openrpc_schema.methods.append(method_schema_merged) return openrpc_schema - return cached_openrpc_discover_method # pyright: ignore + return cached_openrpc_discover_method # type: ignore[no-any-return] # pyright: ignore def openrpc_discover_method( diff --git a/src/flask_jsonrpc/descriptor.py b/src/flask_jsonrpc/descriptor.py index 04b011bf..cb31871a 100644 --- a/src/flask_jsonrpc/descriptor.py +++ b/src/flask_jsonrpc/descriptor.py @@ -33,7 +33,7 @@ # Added in version 3.11. from typing_extensions import Self -from . import typing as fjt # pylint: disable=W0404 +from . import typing as fjt from .helpers import from_python_type if t.TYPE_CHECKING: @@ -48,42 +48,27 @@ def __init__(self: Self, jsonrpc_site: JSONRPCSite) -> None: self.jsonrpc_site = jsonrpc_site self.register(jsonrpc_site) - def register(self: Self, jsonrpc_site: JSONRPCSite) -> None: - def describe() -> fjt.ServiceDescribe: - return self.service_describe() - - fn_annotations = {'return': fjt.ServiceDescribe} - setattr(describe, 'jsonrpc_method_name', JSONRPC_DESCRIBE_METHOD_NAME) # noqa: B010 - setattr(describe, 'jsonrpc_method_sig', fn_annotations) # noqa: B010 - setattr(describe, 'jsonrpc_method_return', fn_annotations.pop('return', None)) # noqa: B010 - setattr(describe, 'jsonrpc_method_params', fn_annotations) # noqa: B010 - setattr(describe, 'jsonrpc_validate', True) # noqa: B010 - setattr(describe, 'jsonrpc_notification', False) # noqa: B010 - setattr(describe, 'jsonrpc_options', {}) # noqa: B010 - jsonrpc_site.register(JSONRPC_DESCRIBE_METHOD_NAME, describe) - self.describe = describe - - def python_type_name(self: Self, pytype: t.Any) -> str: # noqa: ANN401 + def _python_type_name(self: Self, pytype: t.Any) -> str: # noqa: ANN401 return str(from_python_type(pytype)) - def service_method_params_desc( + def _service_method_params_desc( self: Self, view_func: t.Callable[..., t.Any] ) -> list[fjt.ServiceMethodParamsDescribe]: return [ - fjt.ServiceMethodParamsDescribe(name=name, type=self.python_type_name(tp)) + fjt.ServiceMethodParamsDescribe(name=name, type=self._python_type_name(tp)) for name, tp in getattr(view_func, 'jsonrpc_method_params', {}).items() ] - def service_methods_desc(self: Self) -> t.OrderedDict[str, fjt.ServiceMethodDescribe]: + def _service_methods_desc(self: Self) -> t.OrderedDict[str, fjt.ServiceMethodDescribe]: methods: t.OrderedDict[str, fjt.ServiceMethodDescribe] = OrderedDict() for key, view_func in self.jsonrpc_site.view_funcs.items(): name = getattr(view_func, 'jsonrpc_method_name', key) method = fjt.ServiceMethodDescribe( type=JSONRPC_DESCRIBE_SERVICE_METHOD_TYPE, options=getattr(view_func, 'jsonrpc_options', {}), - params=self.service_method_params_desc(view_func), + params=self._service_method_params_desc(view_func), returns=fjt.ServiceMethodReturnsDescribe( - type=self.python_type_name(getattr(view_func, 'jsonrpc_method_return', type(None))) + type=self._python_type_name(getattr(view_func, 'jsonrpc_method_return', type(None))) ), ) # mypyc: pydantic optional value @@ -91,7 +76,7 @@ def service_methods_desc(self: Self) -> t.OrderedDict[str, fjt.ServiceMethodDesc methods[name] = method return methods - def service_server_url(self: Self) -> str: + def _service_server_url(self: Self) -> str: url = urlsplit(self.jsonrpc_site.base_url or self.jsonrpc_site.path) return ( f"{url.scheme!r}://{url.netloc!r}/{(self.jsonrpc_site.path or '').lstrip('/')}" @@ -104,9 +89,24 @@ def service_describe(self: Self) -> fjt.ServiceDescribe: id=f'urn:uuid:{self.jsonrpc_site.uuid}', version=self.jsonrpc_site.version, name=self.jsonrpc_site.name, - servers=[fjt.ServiceServersDescribe(url=self.service_server_url())], # pytype: disable=missing-parameter - methods=self.service_methods_desc(), + servers=[fjt.ServiceServersDescribe(url=self._service_server_url())], # pytype: disable=missing-parameter + methods=self._service_methods_desc(), ) # mypyc: pydantic optional value serv_desc.description = self.jsonrpc_site.__doc__ return serv_desc + + def register(self: Self, jsonrpc_site: JSONRPCSite) -> None: + def describe() -> fjt.ServiceDescribe: + return self.service_describe() + + fn_annotations = {'return': fjt.ServiceDescribe} + setattr(describe, 'jsonrpc_method_name', JSONRPC_DESCRIBE_METHOD_NAME) # noqa: B010 + setattr(describe, 'jsonrpc_method_sig', fn_annotations) # noqa: B010 + setattr(describe, 'jsonrpc_method_return', fn_annotations.pop('return', None)) # noqa: B010 + setattr(describe, 'jsonrpc_method_params', fn_annotations) # noqa: B010 + setattr(describe, 'jsonrpc_validate', True) # noqa: B010 + setattr(describe, 'jsonrpc_notification', False) # noqa: B010 + setattr(describe, 'jsonrpc_options', {}) # noqa: B010 + jsonrpc_site.register(JSONRPC_DESCRIBE_METHOD_NAME, describe) + self.describe = describe diff --git a/src/flask_jsonrpc/encoders.py b/src/flask_jsonrpc/encoders.py index 50680ab1..68f6aaff 100644 --- a/src/flask_jsonrpc/encoders.py +++ b/src/flask_jsonrpc/encoders.py @@ -38,7 +38,9 @@ from pydantic.main import BaseModel -def serializable(obj: t.Any) -> t.Any: # noqa: ANN401 +def serializable(obj: t.Any) -> t.Any: # noqa: ANN401, C901 + if isinstance(obj, (bytes, bytearray)): + return obj.decode('utf-8') if isinstance(obj, Enum): return obj.value if isinstance(obj, PurePath): diff --git a/src/flask_jsonrpc/exceptions.py b/src/flask_jsonrpc/exceptions.py index 6d47e7e8..06c54642 100644 --- a/src/flask_jsonrpc/exceptions.py +++ b/src/flask_jsonrpc/exceptions.py @@ -40,7 +40,7 @@ _("You're lazy...") # this function lazy-loads settings (pragma: no cover) except (ImportError, NameError): - _ = lambda t, *a, **k: t # noqa: E731 pylint: disable=C3001 + _ = lambda t, *a, **k: t # noqa: E731 class JSONRPCError(Exception): diff --git a/src/flask_jsonrpc/funcutils.py b/src/flask_jsonrpc/funcutils.py index efbd9905..70b6d3f3 100644 --- a/src/flask_jsonrpc/funcutils.py +++ b/src/flask_jsonrpc/funcutils.py @@ -26,23 +26,20 @@ # POSSIBILITY OF SUCH DAMAGE. from __future__ import annotations +from enum import Enum import typing as t +from decimal import Decimal import inspect import dataclasses +import typing_inspect + from pydantic import ValidationError from pydantic.main import BaseModel, create_model from . import types as jsonrpc_types from .helpers import from_python_type -# Added in version 3.10. -try: - from types import NoneType, UnionType -except ImportError: # pragma: no cover - UnionType = None # type: ignore - NoneType = type(None) # type: ignore - def loads(param_type: t.Any, param_value: t.Any) -> t.Any: # noqa: ANN401, C901 if param_value is None: @@ -51,33 +48,44 @@ def loads(param_type: t.Any, param_value: t.Any) -> t.Any: # noqa: ANN401, C901 if param_type is t.Any: return param_value + # XXX: The only type of union that is supported is: typing.Union[T, None] or typing.Optional[T] + if typing_inspect.is_union_type(param_type) or typing_inspect.is_optional_type(param_type): + obj_types = t.get_args(param_type) + if len(obj_types) == 2: + actual_type, check_type = obj_types + if actual_type is type(None): + actual_type, check_type = check_type, actual_type + if check_type is type(None): + return loads(actual_type, param_value) + raise TypeError( + 'the only type of union that is supported is: typing.Union[T, None] or typing.Optional[T]' + ) from None + jsonrpc_type = from_python_type(param_type, default=None) if jsonrpc_type is None: if inspect.isclass(param_type): + if issubclass(param_type, Enum): + return param_type(param_value) + + if issubclass(param_type, Decimal): + return param_type(param_value) + if issubclass(param_type, BaseModel): - base_model = t.cast(t.Type[BaseModel], param_type) # type: ignore + base_model = t.cast(type[BaseModel], param_type) # type: ignore model = create_model(base_model.__name__, __base__=base_model) try: return model.model_validate(param_value) except ValidationError as e: raise TypeError(str(e)) from e + # XXX: typing.NamedTuple + if issubclass(param_type, tuple) and not typing_inspect.is_tuple_type(param_type): + return param_type(**param_value) + if dataclasses.is_dataclass(param_type): return param_type(**param_value) return param_type(**param_value) - - # XXX: The only type of union that is supported is: typing.Union[T, None] or typing.Optional[T] - origin_type = t.get_origin(param_type) - if origin_type is not None and (origin_type is t.Union or origin_type is UnionType): - obj_types = t.get_args(param_type) - if len(obj_types) == 2: - actual_type, check_type = obj_types - if check_type is NoneType: - return loads(actual_type, param_value) - raise TypeError( - 'the only type of union that is supported is: typing.Union[T, None] or typing.Optional[T]' - ) from None return param_value if jsonrpc_types.Object.name == jsonrpc_type.name: @@ -94,7 +102,18 @@ def loads(param_type: t.Any, param_value: t.Any) -> t.Any: # noqa: ANN401, C901 item_type = t.get_args(param_type)[0] for item in param_value: loaded_list.append(loads(item_type, item)) - return loaded_list + param_type_origin = t.get_origin(param_type) + return param_type_origin(loaded_list) + + if typing_inspect.is_literal_type(param_type): + return param_value + + if typing_inspect.is_final_type(param_type): + return param_value + + if issubclass(param_type, (bytes, bytearray)): + return param_type(param_value.encode('utf-8')) + return param_value diff --git a/src/flask_jsonrpc/helpers.py b/src/flask_jsonrpc/helpers.py index 878e89f5..811bba2a 100644 --- a/src/flask_jsonrpc/helpers.py +++ b/src/flask_jsonrpc/helpers.py @@ -56,11 +56,9 @@ def urn(name: str, *args: t.Any) -> str: # noqa: ANN401 """ if not name: raise ValueError('name is required') from None - splitted_args = [arg.split('/') for arg in args] - st = ':'.join(list(itertools.chain(*splitted_args))) - st = st.rstrip(':').lstrip(':') - sep = ':' if len(args) > 0 else '' - return f"urn:{name}{sep}{st.replace('::', ':')}".lower() + splitted_args = [arg.replace(':', '/').split('/') for arg in args] + values = ['urn', name] + [st for st in list(itertools.chain(*splitted_args)) if st != ''] + return ':'.join(values).lower() def from_python_type(tp: t.Any, default: JSONRPCNewType | None = Object) -> JSONRPCNewType | None: # noqa: ANN401 @@ -127,6 +125,8 @@ def get(obj: t.Any, path: str, default: t.Any = None) -> t.Any: # noqa: ANN401 return default if not isinstance(obj, dict): return default + if path in obj: + return getitem(obj, path) obj_val = obj keys = path.split('.') diff --git a/src/flask_jsonrpc/site.py b/src/flask_jsonrpc/site.py index 7b975fb9..2e2fc877 100644 --- a/src/flask_jsonrpc/site.py +++ b/src/flask_jsonrpc/site.py @@ -24,7 +24,6 @@ # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -# pylint: disable=R0904 from __future__ import annotations from uuid import UUID, uuid4 @@ -69,6 +68,23 @@ def __init__(self: Self, path: str | None = None, base_url: str | None = None) - self.version: str = JSONRPC_VERSION_DEFAULT self.describe = JSONRPCServiceDescriptor(self).describe + def _is_notification_request(self: Self, req_json: dict[str, t.Any]) -> bool: + return 'id' not in req_json + + def _is_batch_request(self: Self, req_json: t.Any) -> bool: # noqa: ANN401 + return isinstance(req_json, list) + + def _find_error_handler(self: Self, exc: Exception) -> t.Callable[[t.Any], t.Any] | None: + exc_class = type(exc) + if not self.error_handlers: + return None + + for cls in exc_class.__mro__: + handler = self.error_handlers.get(cls) + if handler is not None: + return handler + return None + @property def is_json(self: Self) -> bool: """Check if the mimetype indicates JSON data, either @@ -103,7 +119,7 @@ def dispatch_request(self: Self) -> tuple[t.Any, int, Headers | dict[str, str] | ) from None json_data = self.to_json(request.data) - if self.is_batch_request(json_data): + if self._is_batch_request(json_data): return self.batch_dispatch(json_data) return self.handle_dispatch_except(json_data) @@ -164,7 +180,7 @@ def dispatch( if not view_func: raise MethodNotFoundError(data={'message': f'Method not found: {method_name}'}) from None - if self.is_notification_request(req_json) and not notification: + if self._is_notification_request(req_json) and not notification: raise InvalidRequestError( data={ 'message': f"The method {method_name!r} doesn't allow Notification " @@ -175,17 +191,6 @@ def dispatch( resp_view = self.handle_view_func(view_func, params) return self.make_response(req_json, resp_view) - def _find_error_handler(self: Self, exc: Exception) -> t.Callable[[t.Any], t.Any] | None: - exc_class = type(exc) - if not self.error_handlers: - return None - - for cls in exc_class.__mro__: - handler = self.error_handlers.get(cls) - if handler is not None: - return handler - return None - def handle_dispatch_except( self: Self, req_json: dict[str, t.Any] ) -> tuple[t.Any, int, Headers | dict[str, str] | tuple[str] | list[tuple[str]]]: @@ -201,7 +206,7 @@ def handle_dispatch_except( 'error': e.jsonrpc_format, } return response, e.status_code, JSONRPC_DEFAULT_HTTP_HEADERS - except Exception as e: # pylint: disable=W0703 + except Exception as e: current_app.logger.exception('unexpected error') error_handler = self._find_error_handler(e) jsonrpc_error_data = ( @@ -270,13 +275,7 @@ def make_response( resp_view: t.Any, # noqa: ANN401 ) -> tuple[t.Any, int, Headers | dict[str, str] | tuple[str] | list[tuple[str]]]: rv, status_code, headers = self.unpack_tuple_returns(resp_view) - if self.is_notification_request(req_json): + if self._is_notification_request(req_json): return None, 204, headers resp = {'id': req_json.get('id'), 'jsonrpc': req_json.get('jsonrpc', JSONRPC_VERSION_DEFAULT), 'result': rv} return resp, status_code, headers - - def is_notification_request(self: Self, req_json: dict[str, t.Any]) -> bool: - return 'id' not in req_json - - def is_batch_request(self: Self, req_json: t.Any) -> bool: # noqa: ANN401 - return isinstance(req_json, list) diff --git a/src/flask_jsonrpc/types.py b/src/flask_jsonrpc/types.py index 10339ab3..0b52c1dc 100644 --- a/src/flask_jsonrpc/types.py +++ b/src/flask_jsonrpc/types.py @@ -28,22 +28,15 @@ from types import GeneratorType import typing as t +import inspect from numbers import Real, Integral, Rational from collections import OrderedDict, deque, defaultdict from collections.abc import Mapping -from typing_extensions import ( - Self, # Added in version 3.11. -) +import typing_inspect -from typing_inspect import is_new_type # type: ignore - -# Added in version 3.10. -try: - from types import NoneType, UnionType -except ImportError: # pragma: no cover - UnionType = None # type: ignore - NoneType = type(None) # type: ignore +# Added in version 3.11. +from typing_extensions import Self class JSONRPCNewType: @@ -51,34 +44,35 @@ def __init__(self: Self, name: str, *types: type | tuple[type | tuple[type, ...] self.name = name self.types = types - def check_expected_type(self: Self, expected_type: t.Any) -> bool: # noqa: ANN401 + def _check_expected_type(self: Self, expected_type: t.Any) -> bool: # noqa: ANN401 return any(expected_type is tp for tp in self.types) - def check_expected_types(self: Self, expected_types: t.Any) -> bool: # noqa: ANN401 - return all(any(expt_tp is tp for tp in self.types) for expt_tp in expected_types) + def _check_expected_types(self: Self, expected_types: t.Any) -> bool: # noqa: ANN401 + return all(self.check_type(expt_tp) for expt_tp in expected_types) - def check_type_var(self: Self, expected_type: t.Any) -> bool: # noqa: ANN401 + def _check_type_var(self: Self, expected_type: t.Any) -> bool: # noqa: ANN401 bound_type = getattr(expected_type, '__bound__', None) if bound_type is None: expected_types = getattr(expected_type, '__constraints__', None) if not expected_types: return self is Object - return self.check_expected_types(expected_types) - return self.check_expected_type(bound_type) + return self._check_expected_types(expected_types) + return self._check_expected_type(bound_type) - def check_new_type(self: Self, expected_type: t.Any) -> bool: # noqa: ANN401 + def _check_new_type(self: Self, expected_type: t.Any) -> bool: # noqa: ANN401 super_type = getattr(expected_type, '__supertype__', None) - return self.check_expected_type(super_type) + return self._check_expected_type(super_type) - def check_union(self: Self, expected_type: t.Any) -> bool: # noqa: ANN401 + def _check_union(self: Self, expected_type: t.Any) -> bool: # noqa: ANN401 expected_types = [expt_tp for expt_tp in t.get_args(expected_type) if expt_tp is not type(None)] # noqa: E721 - return self.check_expected_types(expected_types) + return self._check_expected_types(expected_types) - def check_args_type(self: Self, expected_type: t.Any) -> bool: # noqa: ANN401 - expected_types = t.get_args(expected_type) - return self.check_expected_types(expected_types) + def _check_args_type(self: Self, expected_type: t.Any) -> bool: # noqa: ANN401 + args = t.get_args(expected_type) + expected_types = [arg if inspect.isclass(arg) else type(arg) for arg in args] + return self._check_expected_types(expected_types) - def check_type(self: Self, o: t.Any) -> bool: # pylint: disable=R0911 # noqa: ANN401 + def check_type(self: Self, o: t.Any) -> bool: # noqa: ANN401 expected_type = o if expected_type is t.Any: return self is Object @@ -86,29 +80,29 @@ def check_type(self: Self, o: t.Any) -> bool: # pylint: disable=R0911 # noqa: A if expected_type is None or expected_type is t.NoReturn: expected_type = type(None) - if type(expected_type) is t.TypeVar: # pylint: disable=C0123 - return self.check_type_var(expected_type) + if typing_inspect.is_tuple_type(expected_type): + return self is Array - if is_new_type(expected_type): - return self.check_new_type(expected_type) + if typing_inspect.is_typevar(expected_type): + return self._check_type_var(expected_type) - origin_type = t.get_origin(expected_type) - if origin_type is not None: - if origin_type is t.Union or origin_type is UnionType: - return self.check_union(expected_type) + if typing_inspect.is_new_type(expected_type) or hasattr(expected_type, '__supertype__'): + return self._check_new_type(expected_type) - if origin_type is t.Tuple or origin_type is tuple: - return self is Array + if typing_inspect.is_union_type(expected_type): + return self._check_union(expected_type) - if origin_type is t.Literal: - return self.check_args_type(expected_type) + if typing_inspect.is_literal_type(expected_type): + return self._check_args_type(expected_type) - if origin_type is t.Final: - return self.check_args_type(expected_type) + if typing_inspect.is_final_type(expected_type): + return self._check_args_type(expected_type) + origin_type = t.get_origin(expected_type) + if origin_type is not None: expected_type = origin_type - return self.check_expected_type(expected_type) + return self._check_expected_type(expected_type) def __str__(self: Self) -> str: return self.name @@ -116,10 +110,8 @@ def __str__(self: Self) -> str: String = JSONRPCNewType('String', str, bytes, bytearray) Number = JSONRPCNewType('Number', int, float, Real, Rational, Integral) -Object = JSONRPCNewType('Object', dict, t.Dict, defaultdict, OrderedDict, Mapping) -Array = JSONRPCNewType( - 'Array', list, set, t.Set, tuple, t.List, t.NamedTuple, frozenset, t.FrozenSet, GeneratorType, deque -) +Object = JSONRPCNewType('Object', dict, t.Dict, defaultdict, OrderedDict, Mapping) # noqa: UP006 +Array = JSONRPCNewType('Array', list, t.List, set, t.Set, tuple, t.Tuple, frozenset, t.FrozenSet, GeneratorType, deque) # type: ignore[arg-type] # noqa: UP006 Boolean = JSONRPCNewType('Boolean', bool) -Null = JSONRPCNewType('Null', type(None), NoneType) -Types = [String, Number, Object, Array, Boolean, Null] +Null = JSONRPCNewType('Null', type(None), t.Literal[None]) # type: ignore[arg-type] +Types = [Null, String, Number, Boolean, Array, Object] diff --git a/src/flask_jsonrpc/typing.py b/src/flask_jsonrpc/typing.py index 5edbcc3f..6c37972e 100644 --- a/src/flask_jsonrpc/typing.py +++ b/src/flask_jsonrpc/typing.py @@ -26,8 +26,8 @@ # POSSIBILITY OF SUCH DAMAGE. from __future__ import annotations -import sys import typing as t +from collections import OrderedDict from pydantic.main import BaseModel @@ -67,4 +67,4 @@ class ServiceDescribe(BaseModel): name: str description: str | None = None servers: list[ServiceServersDescribe] - methods: t.OrderedDict[str, ServiceMethodDescribe] + methods: OrderedDict[str, ServiceMethodDescribe] diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 0095d168..f3195d4a 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -46,7 +46,6 @@ class APITestCase(unittest.TestCase): def setUp(self: Self) -> None: urllib3.disable_warnings() session = requests.Session() - session.headers.update({'Content-Type': 'application/json'}) session.verify = False self.requests = session diff --git a/tests/test_apps/__init__.py b/tests/integration/shared/__init__.py similarity index 95% rename from tests/test_apps/__init__.py rename to tests/integration/shared/__init__.py index 6a250cab..6023f811 100644 --- a/tests/test_apps/__init__.py +++ b/tests/integration/shared/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2022-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# Copyright (c) 2024-2024, Cenobit Technologies, Inc. http://cenobit.es/ # All rights reserved. # # Redistribution and use in source and binary forms, with or without diff --git a/tests/integration/shared/conftest.py b/tests/integration/shared/conftest.py new file mode 100644 index 00000000..ba80643a --- /dev/null +++ b/tests/integration/shared/conftest.py @@ -0,0 +1,53 @@ +# Copyright (c) 2024-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the Cenobit Technologies nor the names of +# its contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +import os +import typing as t + +import pytest +import urllib3 +import requests + + +@pytest.fixture(scope='session') +def api_url() -> str: + return os.environ['API_URL'] + + +@pytest.fixture(scope='function') +def session() -> t.Generator[requests.Session, None, None]: + urllib3.disable_warnings() + session = requests.Session() + session.verify = False + yield session + + +@pytest.fixture(scope='function') +def async_session() -> t.Generator[requests.Session, None, None]: + urllib3.disable_warnings() + session = requests.Session() + session.verify = False + yield session diff --git a/tests/integration/shared/test_apps b/tests/integration/shared/test_apps new file mode 120000 index 00000000..1834ac43 --- /dev/null +++ b/tests/integration/shared/test_apps @@ -0,0 +1 @@ +../../shared/test_apps \ No newline at end of file diff --git a/tests/integration/test_app.py b/tests/integration/test_app.py index 0bb3225d..27d6924a 100644 --- a/tests/integration/test_app.py +++ b/tests/integration/test_app.py @@ -1,4 +1,3 @@ -# pylint: disable=C0302 # Copyright (c) 2022-2024, Cenobit Technologies, Inc. http://cenobit.es/ # All rights reserved. # @@ -68,7 +67,9 @@ def test_app_greeting_with_different_content_types(self: Self) -> None: self.assertEqual(200, rv.status_code) rv = self.requests.post( - API_URL, data=json.dumps({'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['Python']}) + API_URL, + data=json.dumps({'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['Python']}), + headers={'Content-Type': 'application/json'}, ) self.assertDictEqual({'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Python'}, rv.json()) self.assertEqual(200, rv.status_code) @@ -89,7 +90,10 @@ def test_greeting_raise_parse_error(self: Self) -> None: 'jsonrpc': '2.0', 'error': { 'code': -32700, - 'data': {'message': "Invalid JSON: b'id=1&jsonrpc=2.0&method=jsonrpc.greeting'"}, + 'data': { + 'message': 'Invalid mime type for JSON: application/x-www-form-urlencoded, ' + 'use header Content-Type: application/json' + }, 'message': 'Parse error', 'name': 'ParseError', }, @@ -191,7 +195,7 @@ def test_greeting_raise_invalid_params_error(self: Self) -> None: 'jsonrpc': '2.0', 'error': { 'code': -32602, - 'data': {'message': 'type of argument "name" must be str; got int instead'}, + 'data': {'message': 'argument "name" (int) is not an instance of str'}, 'message': 'Invalid params', 'name': 'InvalidParamsError', }, @@ -209,7 +213,7 @@ def test_greeting_raise_invalid_params_error(self: Self) -> None: 'jsonrpc': '2.0', 'error': { 'code': -32602, - 'data': {'message': 'type of argument "name" must be str; got int instead'}, + 'data': {'message': 'argument "name" (int) is not an instance of str'}, 'message': 'Invalid params', 'name': 'InvalidParamsError', }, @@ -234,1069 +238,3 @@ def test_greeting_raise_method_not_found_error(self: Self) -> None: rv.json(), ) self.assertEqual(400, rv.status_code) - - def test_echo(self: Self) -> None: - rv = self.requests.post( - API_URL, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.echo', 'params': ['Python']} - ) - self.assertDictEqual({'id': 1, 'jsonrpc': '2.0', 'result': 'Python'}, rv.json()) - self.assertEqual(200, rv.status_code) - - rv = self.requests.post( - API_URL, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.echo', 'params': {'string': 'Flask'}} - ) - self.assertDictEqual({'id': 1, 'jsonrpc': '2.0', 'result': 'Flask'}, rv.json()) - self.assertEqual(200, rv.status_code) - - def test_echo_raise_invalid_params_error(self: Self) -> None: - rv = self.requests.post(API_URL, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.echo', 'params': 'Wrong'}) - self.assertDictEqual( - { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32602, - 'data': {'message': 'Parameter structures are by-position (list) or by-name (dict): Wrong'}, - 'message': 'Invalid params', - 'name': 'InvalidParamsError', - }, - }, - rv.json(), - ) - self.assertEqual(400, rv.status_code) - - rv = self.requests.post(API_URL, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.echo', 'params': [1]}) - self.assertDictEqual( - { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32602, - 'data': {'message': 'type of argument "string" must be str; got int instead'}, - 'message': 'Invalid params', - 'name': 'InvalidParamsError', - }, - }, - rv.json(), - ) - self.assertEqual(400, rv.status_code) - - rv = self.requests.post( - API_URL, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.echo', 'params': {'name': 2}} - ) - self.assertDictEqual( - { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32602, - 'data': {'message': "missing a required argument: 'string'"}, - 'message': 'Invalid params', - 'name': 'InvalidParamsError', - }, - }, - rv.json(), - ) - self.assertEqual(400, rv.status_code) - - rv = self.requests.post(API_URL, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.echo'}) - self.assertDictEqual( - { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32602, - 'data': {'message': "missing a required argument: 'string'"}, - 'message': 'Invalid params', - 'name': 'InvalidParamsError', - }, - }, - rv.json(), - ) - self.assertEqual(400, rv.status_code) - - def test_notify(self: Self) -> None: - rv = self.requests.post(API_URL, json={'jsonrpc': '2.0', 'method': 'jsonrpc.notify'}) - self.assertEqual('', rv.text) - self.assertEqual(204, rv.status_code) - - rv = self.requests.post(API_URL, json={'jsonrpc': '2.0', 'method': 'jsonrpc.notify', 'params': ['Some string']}) - self.assertEqual('', rv.text) - self.assertEqual(204, rv.status_code) - - def test_not_allow_notify(self: Self) -> None: - rv = self.requests.post(API_URL, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.not_allow_notify'}) - self.assertDictEqual({'id': 1, 'jsonrpc': '2.0', 'result': 'Not allow notify'}, rv.json()) - self.assertEqual(200, rv.status_code) - - rv = self.requests.post( - API_URL, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.not_allow_notify', 'params': ['Some string']} - ) - self.assertDictEqual({'id': 1, 'jsonrpc': '2.0', 'result': 'Not allow notify'}, rv.json()) - self.assertEqual(200, rv.status_code) - - rv = self.requests.post( - API_URL, json={'jsonrpc': '2.0', 'method': 'jsonrpc.not_allow_notify', 'params': ['Some string']} - ) - self.assertDictEqual( - { - 'error': { - 'code': -32600, - 'data': { - 'message': "The method 'jsonrpc.not_allow_notify' doesn't allow Notification Request " - "object (without an 'id' member)" - }, - 'message': 'Invalid Request', - 'name': 'InvalidRequestError', - }, - 'id': None, - 'jsonrpc': '2.0', - }, - rv.json(), - ) - self.assertEqual(400, rv.status_code) - - def test_fails(self: Self) -> None: - rv = self.requests.post(API_URL, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.fails', 'params': [2]}) - self.assertDictEqual({'id': 1, 'jsonrpc': '2.0', 'result': 2}, rv.json()) - self.assertEqual(200, rv.status_code) - - rv = self.requests.post(API_URL, json={'id': '1', 'jsonrpc': '2.0', 'method': 'jsonrpc.fails', 'params': [1]}) - self.assertDictEqual( - { - 'id': '1', - 'jsonrpc': '2.0', - 'error': { - 'code': -32000, - 'data': {'message': 'number is odd'}, - 'message': 'Server error', - 'name': 'ServerError', - }, - }, - rv.json(), - ) - self.assertEqual(500, rv.status_code) - - def test_strange_echo(self: Self) -> None: - data = { - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.strangeEcho', - 'params': ['string', {'a': 1}, ['a', 'b', 'c'], 23, 'Flask'], - } - rv = self.requests.post(API_URL, json=data) - self.assertDictEqual( - {'id': 1, 'jsonrpc': '2.0', 'result': ['string', {'a': 1}, ['a', 'b', 'c'], 23, 'Flask']}, rv.json() - ) - self.assertEqual(200, rv.status_code) - - data = { - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.strangeEcho', - 'params': ['string', {'a': 1}, ['a', 'b', 'c'], 23], - } - rv = self.requests.post(API_URL, json=data) - self.assertDictEqual( - {'id': 1, 'jsonrpc': '2.0', 'result': ['string', {'a': 1}, ['a', 'b', 'c'], 23, 'Default']}, rv.json() - ) - self.assertEqual(200, rv.status_code) - - def test_sum(self: Self) -> None: - data = {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.sum', 'params': [1, 3]} - rv = self.requests.post(API_URL, json=data) - self.assertDictEqual({'id': 1, 'jsonrpc': '2.0', 'result': 4}, rv.json()) - self.assertEqual(200, rv.status_code) - - data = {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.sum', 'params': [0.5, 1.5]} - rv = self.requests.post(API_URL, json=data) - self.assertDictEqual({'id': 1, 'jsonrpc': '2.0', 'result': 2.0}, rv.json()) - self.assertEqual(200, rv.status_code) - - def test_decorators(self: Self) -> None: - data = {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.decorators', 'params': ['Python']} - rv = self.requests.post(API_URL, json=data) - self.assertDictEqual({'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Python from decorator, ;)'}, rv.json()) - self.assertEqual(200, rv.status_code) - - def test_return_status_code(self: Self) -> None: - rv = self.requests.post( - API_URL, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.returnStatusCode', 'params': ['OK']} - ) - self.assertDictEqual({'id': 1, 'jsonrpc': '2.0', 'result': 'Status Code OK'}, rv.json()) - self.assertEqual(201, rv.status_code) - - def test_return_headers(self: Self) -> None: - rv = self.requests.post( - API_URL, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.returnHeaders', 'params': ['OK']} - ) - self.assertDictEqual({'id': 1, 'jsonrpc': '2.0', 'result': 'Headers OK'}, rv.json()) - self.assertEqual(200, rv.status_code) - self.assertEqual('1', rv.headers['X-JSONRPC']) - - def test_return_status_code_and_headers(self: Self) -> None: - rv = self.requests.post( - API_URL, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.returnStatusCodeAndHeaders', 'params': ['OK']} - ) - self.assertDictEqual({'id': 1, 'jsonrpc': '2.0', 'result': 'Status Code and Headers OK'}, rv.json()) - self.assertEqual(400, rv.status_code) - self.assertEqual('1', rv.headers['X-JSONRPC']) - - def test_not_validate_method(self: Self) -> None: - rv = self.requests.post( - API_URL, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.not_validate', 'params': ['OK']} - ) - self.assertDictEqual({'id': 1, 'jsonrpc': '2.0', 'result': 'Not validate: OK'}, rv.json()) - self.assertEqual(200, rv.status_code) - - def test_mixin_not_validate_method(self: Self) -> None: - rv = self.requests.post( - API_URL, - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.mixin_not_validate', - 'params': [':)', 1, 3.2, ':D', [1, 2, 3], {1: 1}], - }, - ) - self.assertDictEqual( - {'id': 1, 'jsonrpc': '2.0', 'result': "Not validate: :) 1 3.2 :D [1, 2, 3] {'1': 1}"}, rv.json() - ) - self.assertEqual(200, rv.status_code) - - rv = self.requests.post( - API_URL, - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.mixin_not_validate', - 'params': {'s': ':)', 't': 1, 'u': 3.2, 'v': ':D', 'x': [1, 2, 3], 'z': {1: 1}}, - }, - ) - self.assertDictEqual( - {'id': 1, 'jsonrpc': '2.0', 'result': "Not validate: :) 1 3.2 :D [1, 2, 3] {'1': 1}"}, rv.json() - ) - self.assertEqual(200, rv.status_code) - - def test_no_return_method(self: Self) -> None: - rv = self.requests.post(API_URL, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.noReturn', 'params': []}) - self.assertDictEqual( - { - 'error': { - 'code': -32000, - 'data': {'message': 'no return'}, - 'message': 'Server error', - 'name': 'ServerError', - }, - 'id': 1, - 'jsonrpc': '2.0', - }, - rv.json(), - ) - self.assertEqual(500, rv.status_code) - - def test_with_rcp_batch(self: Self) -> None: - rv = self.requests.post(API_URL, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting'}) - self.assertDictEqual({'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Flask JSON-RPC'}, rv.json()) - self.assertEqual(200, rv.status_code) - - rv = self.requests.post( - API_URL, - json=[ - {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['Python']}, - {'id': 2, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['Flask']}, - {'id': 3, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['JSON-RCP']}, - ], - ) - self.assertEqual( - [ - {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Python'}, - {'id': 2, 'jsonrpc': '2.0', 'result': 'Hello Flask'}, - {'id': 3, 'jsonrpc': '2.0', 'result': 'Hello JSON-RCP'}, - ], - rv.json(), - ) - self.assertEqual(200, rv.status_code) - - rv = self.requests.post( - API_URL, - json=[ - {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['Python']}, - {'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['Flask']}, - {'id': 3, 'jsonrpc': '2.0', 'params': ['Flask']}, - {'id': 4, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['JSON-RCP']}, - ], - ) - self.assertEqual( - [ - {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Python'}, - { - 'id': 3, - 'jsonrpc': '2.0', - 'error': { - 'code': -32600, - 'data': {'message': "Invalid JSON: {'id': 3, 'jsonrpc': '2.0', 'params': ['Flask']}"}, - 'message': 'Invalid Request', - 'name': 'InvalidRequestError', - }, - }, - {'id': 4, 'jsonrpc': '2.0', 'result': 'Hello JSON-RCP'}, - ], - rv.json(), - ) - self.assertEqual(200, rv.status_code) - - rv = self.requests.post(API_URL, json={'id': 2, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting'}) - self.assertDictEqual({'id': 2, 'jsonrpc': '2.0', 'result': 'Hello Flask JSON-RPC'}, rv.json()) - self.assertEqual(200, rv.status_code) - - def test_class(self: Self) -> None: - rv = self.requests.post(API_URL, json={'id': 1, 'jsonrpc': '2.0', 'method': 'classapp.index'}) - self.assertDictEqual({'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Flask JSON-RPC'}, rv.json()) - self.assertEqual(200, rv.status_code) - - rv = self.requests.post(API_URL, json={'id': 1, 'jsonrpc': '2.0', 'method': 'greeting', 'params': ['Python']}) - self.assertDictEqual({'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Python'}, rv.json()) - self.assertEqual(200, rv.status_code) - - rv = self.requests.post( - API_URL, json={'id': 1, 'jsonrpc': '2.0', 'method': 'hello', 'params': {'name': 'Flask'}} - ) - self.assertDictEqual({'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Flask'}, rv.json()) - self.assertEqual(200, rv.status_code) - - rv = self.requests.post(API_URL, json={'id': 1, 'jsonrpc': '2.0', 'method': 'echo', 'params': ['Python', 1]}) - self.assertDictEqual({'id': 1, 'jsonrpc': '2.0', 'result': 'Python'}, rv.json()) - self.assertEqual(200, rv.status_code) - - rv = self.requests.post(API_URL, json={'jsonrpc': '2.0', 'method': 'notify', 'params': ['Python']}) - self.assertEqual(204, rv.status_code) - - rv = self.requests.post(API_URL, json={'id': 1, 'jsonrpc': '2.0', 'method': 'fails', 'params': [13]}) - self.assertDictEqual( - { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32000, - 'data': {'message': 'number is odd'}, - 'message': 'Server error', - 'name': 'ServerError', - }, - }, - rv.json(), - ) - self.assertEqual(500, rv.status_code) - - def test_app_with_pythonclass(self: Self) -> None: - rv = self.requests.post( - API_URL, - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createColor', - 'params': {'color': {'name': 'Blue', 'tag': 'good'}}, - }, - ) - self.assertEqual(200, rv.status_code) - self.assertDictEqual({'id': 1, 'jsonrpc': '2.0', 'result': {'id': 1, 'name': 'Blue', 'tag': 'good'}}, rv.json()) - - rv = self.requests.post( - API_URL, - json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.createColor', 'params': {'color': {'name': 'Red'}}}, - ) - self.assertEqual(400, rv.status_code) - data = rv.json() - self.assertEqual(1, data['id']) - self.assertEqual('2.0', data['jsonrpc']) - self.assertEqual(-32602, data['error']['code']) - self.assertTrue("missing 1 required positional argument: 'tag'" in data['error']['data']['message']) - self.assertEqual('Invalid params', data['error']['message']) - self.assertEqual('InvalidParamsError', data['error']['name']) - - rv = self.requests.post( - API_URL, - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createManyColor', - 'params': {'colors': [{'name': 'Blue', 'tag': 'good'}, {'name': 'Red', 'tag': 'bad'}]}, - }, - ) - self.assertEqual(200, rv.status_code) - self.assertDictEqual( - { - 'id': 1, - 'jsonrpc': '2.0', - 'result': [{'id': 0, 'name': 'Blue', 'tag': 'good'}, {'id': 1, 'name': 'Red', 'tag': 'bad'}], - }, - rv.json(), - ) - - rv = self.requests.post( - API_URL, - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createManyColor', - 'params': {'colors': [{'name': 'Blue', 'tag': 'good'}], 'color': {'name': 'Red', 'tag': 'bad'}}, - }, - ) - self.assertEqual(200, rv.status_code) - self.assertDictEqual( - { - 'id': 1, - 'jsonrpc': '2.0', - 'result': [{'id': 0, 'name': 'Blue', 'tag': 'good'}, {'id': 1, 'name': 'Red', 'tag': 'bad'}], - }, - rv.json(), - ) - - rv = self.requests.post( - API_URL, - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createManyColor', - 'params': [ - [{'name': 'Blue', 'tag': 'good'}, {'name': 'Red', 'tag': 'bad'}], - {'name': 'Green', 'tag': 'yay'}, - ], - }, - ) - self.assertEqual(200, rv.status_code) - self.assertDictEqual( - { - 'id': 1, - 'jsonrpc': '2.0', - 'result': [ - {'id': 0, 'name': 'Blue', 'tag': 'good'}, - {'id': 1, 'name': 'Red', 'tag': 'bad'}, - {'id': 2, 'name': 'Green', 'tag': 'yay'}, - ], - }, - rv.json(), - ) - - rv = self.requests.post( - API_URL, - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createManyFixColor', - 'params': {'colors': {'1': {'name': 'Blue', 'tag': 'good'}}}, - }, - ) - self.assertEqual(200, rv.status_code) - self.assertDictEqual( - {'id': 1, 'jsonrpc': '2.0', 'result': [{'id': 1, 'name': 'Blue', 'tag': 'good'}]}, rv.json() - ) - - rv = self.requests.post( - API_URL, - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.removeColor', - 'params': {'color': {'id': 1, 'name': 'Blue', 'tag': 'good'}}, - }, - ) - self.assertEqual(200, rv.status_code) - self.assertDictEqual({'id': 1, 'jsonrpc': '2.0', 'result': {'id': 1, 'name': 'Blue', 'tag': 'good'}}, rv.json()) - - rv = self.requests.post( - API_URL, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removeColor', 'params': {'color': None}} - ) - self.assertEqual(200, rv.status_code) - self.assertDictEqual({'id': 1, 'jsonrpc': '2.0', 'result': None}, rv.json()) - - rv = self.requests.post( - API_URL, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removeColor', 'params': []} - ) - self.assertEqual(200, rv.status_code) - self.assertDictEqual({'id': 1, 'jsonrpc': '2.0', 'result': None}, rv.json()) - - rv = self.requests.post( - API_URL, - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.removeColor', - 'params': {'color': {'id': 100, 'name': 'Blue', 'tag': 'good'}}, - }, - ) - self.assertEqual(500, rv.status_code) - self.assertDictEqual( - { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32000, - 'data': {'color_id': 100, 'reason': 'The color with an ID greater than 10 does not exist.'}, - 'message': 'Server error', - 'name': 'ServerError', - }, - }, - rv.json(), - ) - - def test_app_with_dataclass(self: Self) -> None: - rv = self.requests.post( - API_URL, - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createCar', - 'params': {'car': {'name': 'Fusca', 'tag': 'blue'}}, - }, - ) - self.assertEqual(200, rv.status_code) - self.assertDictEqual( - {'id': 1, 'jsonrpc': '2.0', 'result': {'id': 1, 'name': 'Fusca', 'tag': 'blue'}}, rv.json() - ) - - rv = self.requests.post( - API_URL, - json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.createCar', 'params': {'car': {'name': 'Fusca'}}}, - ) - self.assertEqual(400, rv.status_code) - data = rv.json() - self.assertEqual(1, data['id']) - self.assertEqual('2.0', data['jsonrpc']) - self.assertEqual(-32602, data['error']['code']) - self.assertTrue("missing 1 required positional argument: 'tag'" in data['error']['data']['message']) - self.assertEqual('Invalid params', data['error']['message']) - self.assertEqual('InvalidParamsError', data['error']['name']) - - rv = self.requests.post( - API_URL, - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createManyCar', - 'params': {'cars': [{'name': 'Fusca', 'tag': 'blue'}, {'name': 'Kombi', 'tag': 'yellow'}]}, - }, - ) - self.assertEqual(200, rv.status_code) - self.assertDictEqual( - { - 'id': 1, - 'jsonrpc': '2.0', - 'result': [{'id': 0, 'name': 'Fusca', 'tag': 'blue'}, {'id': 1, 'name': 'Kombi', 'tag': 'yellow'}], - }, - rv.json(), - ) - - rv = self.requests.post( - API_URL, - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createManyCar', - 'params': {'cars': [{'name': 'Fusca', 'tag': 'blue'}], 'car': {'name': 'Kombi', 'tag': 'yellow'}}, - }, - ) - self.assertEqual(200, rv.status_code) - self.assertDictEqual( - { - 'id': 1, - 'jsonrpc': '2.0', - 'result': [{'id': 0, 'name': 'Fusca', 'tag': 'blue'}, {'id': 1, 'name': 'Kombi', 'tag': 'yellow'}], - }, - rv.json(), - ) - - rv = self.requests.post( - API_URL, - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createManyCar', - 'params': [ - [{'name': 'Fusca', 'tag': 'blue'}, {'name': 'Kombi', 'tag': 'yellow'}], - {'name': 'Gol', 'tag': 'white'}, - ], - }, - ) - self.assertEqual(200, rv.status_code) - self.assertDictEqual( - { - 'id': 1, - 'jsonrpc': '2.0', - 'result': [ - {'id': 0, 'name': 'Fusca', 'tag': 'blue'}, - {'id': 1, 'name': 'Kombi', 'tag': 'yellow'}, - {'id': 2, 'name': 'Gol', 'tag': 'white'}, - ], - }, - rv.json(), - ) - - rv = self.requests.post( - API_URL, - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createManyFixCar', - 'params': {'cars': {'1': {'name': 'Fusca', 'tag': 'blue'}}}, - }, - ) - self.assertEqual(200, rv.status_code) - self.assertDictEqual( - {'id': 1, 'jsonrpc': '2.0', 'result': [{'id': 1, 'name': 'Fusca', 'tag': 'blue'}]}, rv.json() - ) - - rv = self.requests.post( - API_URL, - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.removeCar', - 'params': {'car': {'id': 1, 'name': 'Fusca', 'tag': 'blue'}}, - }, - ) - self.assertEqual(200, rv.status_code) - self.assertDictEqual( - {'id': 1, 'jsonrpc': '2.0', 'result': {'id': 1, 'name': 'Fusca', 'tag': 'blue'}}, rv.json() - ) - - rv = self.requests.post( - API_URL, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removeCar', 'params': {'car': None}} - ) - self.assertEqual(200, rv.status_code) - self.assertDictEqual({'id': 1, 'jsonrpc': '2.0', 'result': None}, rv.json()) - - rv = self.requests.post(API_URL, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removeCar', 'params': []}) - self.assertEqual(200, rv.status_code) - self.assertDictEqual({'id': 1, 'jsonrpc': '2.0', 'result': None}, rv.json()) - - rv = self.requests.post( - API_URL, - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.removeCar', - 'params': {'car': {'id': 100, 'name': 'Fusca', 'tag': 'blue'}}, - }, - ) - self.assertEqual(500, rv.status_code) - self.assertDictEqual( - { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32000, - 'data': {'car_id': 100, 'reason': 'The car with an ID greater than 10 does not exist.'}, - 'message': 'Server error', - 'name': 'ServerError', - }, - }, - rv.json(), - ) - - def test_app_with_pydantic(self: Self) -> None: - rv = self.requests.post( - API_URL, - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createPet', - 'params': {'pet': {'name': 'Eve', 'tag': 'dog'}}, - }, - ) - self.assertEqual(200, rv.status_code) - self.assertDictEqual({'id': 1, 'jsonrpc': '2.0', 'result': {'id': 1, 'name': 'Eve', 'tag': 'dog'}}, rv.json()) - - rv = self.requests.post( - API_URL, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.createPet', 'params': {'pet': {'name': 'Eve'}}} - ) - self.assertEqual(400, rv.status_code) - self.assertDictEqual( - { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32602, - 'data': { - 'message': '1 validation error for NewPet\n' - 'tag\n' - " Field required [type=missing, input_value={'name': 'Eve'}, " - 'input_type=dict]\n' - ' For further information visit ' - 'https://errors.pydantic.dev/2.9/v/missing' - }, - 'message': 'Invalid params', - 'name': 'InvalidParamsError', - }, - }, - rv.json(), - ) - - rv = self.requests.post( - API_URL, - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createManyPet', - 'params': {'pets': [{'name': 'Eve', 'tag': 'dog'}, {'name': 'Lou', 'tag': 'dog'}]}, - }, - ) - self.assertEqual(200, rv.status_code) - self.assertDictEqual( - { - 'id': 1, - 'jsonrpc': '2.0', - 'result': [{'id': 0, 'name': 'Eve', 'tag': 'dog'}, {'id': 1, 'name': 'Lou', 'tag': 'dog'}], - }, - rv.json(), - ) - - rv = self.requests.post( - API_URL, - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createManyPet', - 'params': {'pets': [{'name': 'Eve', 'tag': 'dog'}], 'pet': {'name': 'Lou', 'tag': 'dog'}}, - }, - ) - self.assertEqual(200, rv.status_code) - self.assertDictEqual( - { - 'id': 1, - 'jsonrpc': '2.0', - 'result': [{'id': 0, 'name': 'Eve', 'tag': 'dog'}, {'id': 1, 'name': 'Lou', 'tag': 'dog'}], - }, - rv.json(), - ) - - rv = self.requests.post( - API_URL, - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createManyPet', - 'params': [ - [{'name': 'Eve', 'tag': 'dog'}, {'name': 'Lou', 'tag': 'dog'}], - {'name': 'Tequila', 'tag': 'cat'}, - ], - }, - ) - self.assertEqual(200, rv.status_code) - self.assertDictEqual( - { - 'id': 1, - 'jsonrpc': '2.0', - 'result': [ - {'id': 0, 'name': 'Eve', 'tag': 'dog'}, - {'id': 1, 'name': 'Lou', 'tag': 'dog'}, - {'id': 2, 'name': 'Tequila', 'tag': 'cat'}, - ], - }, - rv.json(), - ) - - rv = self.requests.post( - API_URL, - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createManyFixPet', - 'params': {'pets': {'1': {'name': 'Eve', 'tag': 'dog'}}}, - }, - ) - self.assertEqual(200, rv.status_code) - self.assertDictEqual({'id': 1, 'jsonrpc': '2.0', 'result': [{'id': 1, 'name': 'Eve', 'tag': 'dog'}]}, rv.json()) - - rv = self.requests.post( - API_URL, - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.removePet', - 'params': {'pet': {'id': 1, 'name': 'Eve', 'tag': 'dog'}}, - }, - ) - self.assertEqual(200, rv.status_code) - self.assertDictEqual({'id': 1, 'jsonrpc': '2.0', 'result': {'id': 1, 'name': 'Eve', 'tag': 'dog'}}, rv.json()) - - rv = self.requests.post( - API_URL, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removePet', 'params': {'pet': None}} - ) - self.assertEqual(200, rv.status_code) - self.assertDictEqual({'id': 1, 'jsonrpc': '2.0', 'result': None}, rv.json()) - - rv = self.requests.post(API_URL, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removePet', 'params': []}) - self.assertEqual(200, rv.status_code) - self.assertDictEqual({'id': 1, 'jsonrpc': '2.0', 'result': None}, rv.json()) - - rv = self.requests.post( - API_URL, - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.removePet', - 'params': {'pet': {'id': 100, 'name': 'Lou', 'tag': 'dog'}}, - }, - ) - self.assertEqual(500, rv.status_code) - self.assertDictEqual( - { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32000, - 'data': {'pet_id': 100, 'reason': 'The pet with an ID greater than 10 does not exist.'}, - 'message': 'Server error', - 'name': 'ServerError', - }, - }, - rv.json(), - ) - - def test_system_describe(self: Self) -> None: - rv = self.requests.post(API_URL, json={'id': 1, 'jsonrpc': '2.0', 'method': 'rpc.describe'}) - json_data = rv.json() - self.assertEqual(1, json_data['id']) - self.assertEqual('2.0', json_data['jsonrpc']) - self.assertEqual('Flask-JSONRPC', json_data['result']['name']) - self.assertTrue('description' not in json_data['result']) - self.assertEqual('2.0', json_data['result']['version']) - self.assertIsNotNone(json_data['result']['servers']) - self.assertDictEqual( - { - 'classapp.index': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'name', 'type': 'String'}], - 'returns': {'type': 'String'}, - 'type': 'method', - }, - 'echo': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'string', 'type': 'String'}, {'name': '_some', 'type': 'Object'}], - 'returns': {'type': 'String'}, - 'type': 'method', - }, - 'fails': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'n', 'type': 'Number'}], - 'returns': {'type': 'Number'}, - 'type': 'method', - }, - 'greeting': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'name', 'type': 'String'}], - 'returns': {'type': 'String'}, - 'type': 'method', - }, - 'hello': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'name', 'type': 'String'}], - 'returns': {'type': 'String'}, - 'type': 'method', - }, - 'jsonrpc.createCar': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'car', 'type': 'Object'}], - 'returns': {'type': 'Object'}, - 'type': 'method', - }, - 'jsonrpc.createColor': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'color', 'type': 'Object'}], - 'returns': {'type': 'Object'}, - 'type': 'method', - }, - 'jsonrpc.createManyCar': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'cars', 'type': 'Array'}, {'name': 'car', 'type': 'Object'}], - 'returns': {'type': 'Array'}, - 'type': 'method', - }, - 'jsonrpc.createManyColor': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'colors', 'type': 'Array'}, {'name': 'color', 'type': 'Object'}], - 'returns': {'type': 'Array'}, - 'type': 'method', - }, - 'jsonrpc.createManyFixCar': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'cars', 'type': 'Object'}], - 'returns': {'type': 'Array'}, - 'type': 'method', - }, - 'jsonrpc.createManyFixColor': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'colors', 'type': 'Object'}], - 'returns': {'type': 'Array'}, - 'type': 'method', - }, - 'jsonrpc.createManyFixPet': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'pets', 'type': 'Object'}], - 'returns': {'type': 'Array'}, - 'type': 'method', - }, - 'jsonrpc.createManyPet': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'pets', 'type': 'Array'}, {'name': 'pet', 'type': 'Object'}], - 'returns': {'type': 'Array'}, - 'type': 'method', - }, - 'jsonrpc.createPet': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'pet', 'type': 'Object'}], - 'returns': {'type': 'Object'}, - 'type': 'method', - }, - 'jsonrpc.decorators': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'string', 'type': 'String'}], - 'returns': {'type': 'String'}, - 'type': 'method', - }, - 'jsonrpc.echo': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'string', 'type': 'String'}, {'name': '_some', 'type': 'Object'}], - 'returns': {'type': 'String'}, - 'type': 'method', - }, - 'jsonrpc.fails': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'n', 'type': 'Number'}], - 'returns': {'type': 'Number'}, - 'type': 'method', - }, - 'jsonrpc.greeting': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'name', 'type': 'String'}], - 'returns': {'type': 'String'}, - 'type': 'method', - }, - 'jsonrpc.invalidUnion1': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'color', 'type': 'Object'}], - 'returns': {'type': 'Object'}, - 'type': 'method', - }, - 'jsonrpc.invalidUnion2': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'color', 'type': 'Object'}], - 'returns': {'type': 'Object'}, - 'type': 'method', - }, - 'jsonrpc.literalType': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'x', 'type': 'Object'}], - 'returns': {'type': 'Object'}, - 'type': 'method', - }, - 'jsonrpc.mixin_not_validate': { - 'options': {'notification': True, 'validate': False}, - 'params': [ - {'name': 's', 'type': 'Object'}, - {'name': 't', 'type': 'Number'}, - {'name': 'u', 'type': 'Object'}, - {'name': 'v', 'type': 'String'}, - {'name': 'x', 'type': 'Object'}, - {'name': 'z', 'type': 'Object'}, - ], - 'returns': {'type': 'Object'}, - 'type': 'method', - }, - 'jsonrpc.noReturn': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': '_string', 'type': 'String'}], - 'returns': {'type': 'Null'}, - 'type': 'method', - }, - 'jsonrpc.not_allow_notify': { - 'options': {'notification': False, 'validate': True}, - 'params': [{'name': '_string', 'type': 'String'}], - 'returns': {'type': 'String'}, - 'type': 'method', - }, - 'jsonrpc.not_validate': { - 'options': {'notification': True, 'validate': False}, - 'params': [{'name': 's', 'type': 'Object'}], - 'returns': {'type': 'Object'}, - 'type': 'method', - }, - 'jsonrpc.notify': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': '_string', 'type': 'String'}], - 'returns': {'type': 'Null'}, - 'type': 'method', - }, - 'jsonrpc.removeCar': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'car', 'type': 'Object'}], - 'returns': {'type': 'Object'}, - 'type': 'method', - }, - 'jsonrpc.removeColor': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'color', 'type': 'Object'}], - 'returns': {'type': 'Object'}, - 'type': 'method', - }, - 'jsonrpc.removePet': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'pet', 'type': 'Object'}], - 'returns': {'type': 'Object'}, - 'type': 'method', - }, - 'jsonrpc.returnHeaders': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 's', 'type': 'String'}], - 'returns': {'type': 'Array'}, - 'type': 'method', - }, - 'jsonrpc.returnStatusCode': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 's', 'type': 'String'}], - 'returns': {'type': 'Array'}, - 'type': 'method', - }, - 'jsonrpc.returnStatusCodeAndHeaders': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 's', 'type': 'String'}], - 'returns': {'type': 'Array'}, - 'type': 'method', - }, - 'jsonrpc.strangeEcho': { - 'options': {'notification': True, 'validate': True}, - 'params': [ - {'name': 'string', 'type': 'String'}, - {'name': 'omg', 'type': 'Object'}, - {'name': 'wtf', 'type': 'Array'}, - {'name': 'nowai', 'type': 'Number'}, - {'name': 'yeswai', 'type': 'String'}, - ], - 'returns': {'type': 'Array'}, - 'type': 'method', - }, - 'jsonrpc.sum': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'a', 'type': 'Number'}, {'name': 'b', 'type': 'Number'}], - 'returns': {'type': 'Number'}, - 'type': 'method', - }, - 'not_allow_notify': { - 'options': {'notification': False, 'validate': True}, - 'params': [{'name': '_string', 'type': 'String'}], - 'returns': {'type': 'String'}, - 'type': 'method', - }, - 'notify': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': '_string', 'type': 'String'}], - 'returns': {'type': 'Null'}, - 'type': 'method', - }, - 'rpc.describe': {'options': {}, 'params': [], 'returns': {'type': 'Object'}, 'type': 'method'}, - }, - json_data['result']['methods'], - ) - - self.assertEqual(200, rv.status_code) diff --git a/tests/integration/test_browse.py b/tests/integration/test_browse.py index daa5f7ca..2579e5aa 100644 --- a/tests/integration/test_browse.py +++ b/tests/integration/test_browse.py @@ -24,7 +24,6 @@ # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -# pylint: disable=duplicate-code,too-many-public-methods import os from selenium.webdriver.common.by import By diff --git a/tests/shared/test_apps/__init__.py b/tests/shared/test_apps/__init__.py new file mode 100644 index 00000000..6023f811 --- /dev/null +++ b/tests/shared/test_apps/__init__.py @@ -0,0 +1,26 @@ +# Copyright (c) 2024-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the Cenobit Technologies nor the names of +# its contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. diff --git a/tests/shared/test_apps/features/__init__.py b/tests/shared/test_apps/features/__init__.py new file mode 100644 index 00000000..6023f811 --- /dev/null +++ b/tests/shared/test_apps/features/__init__.py @@ -0,0 +1,26 @@ +# Copyright (c) 2024-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the Cenobit Technologies nor the names of +# its contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. diff --git a/tests/shared/test_apps/features/objects/__init__.py b/tests/shared/test_apps/features/objects/__init__.py new file mode 100644 index 00000000..6023f811 --- /dev/null +++ b/tests/shared/test_apps/features/objects/__init__.py @@ -0,0 +1,26 @@ +# Copyright (c) 2024-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the Cenobit Technologies nor the names of +# its contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. diff --git a/tests/shared/test_apps/features/objects/test_pydantic_models.py b/tests/shared/test_apps/features/objects/test_pydantic_models.py new file mode 100644 index 00000000..3b9b2ac9 --- /dev/null +++ b/tests/shared/test_apps/features/objects/test_pydantic_models.py @@ -0,0 +1,219 @@ +# Copyright (c) 2024-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the Cenobit Technologies nor the names of +# its contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +import typing as t + +if t.TYPE_CHECKING: + from requests import Session + + +def test_app_with_pydantic(session: 'Session', api_url: str) -> None: + rv = session.post( + f'{api_url}/objects/pydantic-models', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createPet', + 'params': {'pet': {'name': 'Eve', 'tag': 'dog'}}, + }, + ) + assert rv.status_code == 200 + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': {'id': 1, 'name': 'Eve', 'tag': 'dog'}} + + rv = session.post( + f'{api_url}/objects/pydantic-models', + json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.createPet', 'params': {'pet': {'name': 'Eve'}}}, + ) + assert rv.status_code == 400 + assert rv.json() == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32602, + 'data': { + 'message': '1 validation error for NewPet\n' + 'tag\n' + " Field required [type=missing, input_value={'name': 'Eve'}, " + 'input_type=dict]\n' + ' For further information visit ' + 'https://errors.pydantic.dev/2.9/v/missing' + }, + 'message': 'Invalid params', + 'name': 'InvalidParamsError', + }, + } + + rv = session.post( + f'{api_url}/objects/pydantic-models', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createManyPet', + 'params': {'pets': [{'name': 'Eve', 'tag': 'dog'}, {'name': 'Lou', 'tag': 'dog'}]}, + }, + ) + assert rv.status_code == 200 + assert rv.json() == { + 'id': 1, + 'jsonrpc': '2.0', + 'result': [{'id': 0, 'name': 'Eve', 'tag': 'dog'}, {'id': 1, 'name': 'Lou', 'tag': 'dog'}], + } + + rv = session.post( + f'{api_url}/objects/pydantic-models', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createManyPet', + 'params': {'pets': [{'name': 'Eve', 'tag': 'dog'}], 'pet': {'name': 'Lou', 'tag': 'dog'}}, + }, + ) + assert rv.status_code == 200 + assert rv.json() == { + 'id': 1, + 'jsonrpc': '2.0', + 'result': [{'id': 0, 'name': 'Eve', 'tag': 'dog'}, {'id': 1, 'name': 'Lou', 'tag': 'dog'}], + } + + rv = session.post( + f'{api_url}/objects/pydantic-models', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createManyPet', + 'params': [ + [{'name': 'Eve', 'tag': 'dog'}, {'name': 'Lou', 'tag': 'dog'}], + {'name': 'Tequila', 'tag': 'cat'}, + ], + }, + ) + assert rv.status_code == 200 + assert rv.json() == { + 'id': 1, + 'jsonrpc': '2.0', + 'result': [ + {'id': 0, 'name': 'Eve', 'tag': 'dog'}, + {'id': 1, 'name': 'Lou', 'tag': 'dog'}, + {'id': 2, 'name': 'Tequila', 'tag': 'cat'}, + ], + } + + rv = session.post( + f'{api_url}/objects/pydantic-models', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createManyFixPet', + 'params': {'pets': {'1': {'name': 'Eve', 'tag': 'dog'}}}, + }, + ) + assert rv.status_code == 200 + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': [{'id': 1, 'name': 'Eve', 'tag': 'dog'}]} + + rv = session.post( + f'{api_url}/objects/pydantic-models', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.removePet', + 'params': {'pet': {'id': 1, 'name': 'Eve', 'tag': 'dog'}}, + }, + ) + assert rv.status_code == 200 + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': {'id': 1, 'name': 'Eve', 'tag': 'dog'}} + + rv = session.post( + f'{api_url}/objects/pydantic-models', + json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removePet', 'params': {'pet': None}}, + ) + assert rv.status_code == 200 + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': None} + + rv = session.post( + f'{api_url}/objects/pydantic-models', + json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removePet', 'params': []}, + ) + assert rv.status_code == 200 + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': None} + + rv = session.post( + f'{api_url}/objects/pydantic-models', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.removePet', + 'params': {'pet': {'id': 100, 'name': 'Lou', 'tag': 'dog'}}, + }, + ) + assert rv.status_code == 500 + assert rv.json() == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32000, + 'data': {'pet_id': 100, 'reason': 'The pet with an ID greater than 10 does not exist.'}, + 'message': 'Server error', + 'name': 'ServerError', + }, + } + + +def test_app_system_describe(session: 'Session', api_url: str) -> None: + rv = session.post(f'{api_url}/objects/pydantic-models', json={'id': 1, 'jsonrpc': '2.0', 'method': 'rpc.describe'}) + data = rv.json() + assert data['id'] == 1 + assert data['jsonrpc'] == '2.0' + assert data['result']['name'] == 'Flask-JSONRPC' + assert data['result']['version'] == '2.0' + assert data['result']['servers'] is not None + assert 'url' in data['result']['servers'][0] + assert data['result']['methods'] == { + 'jsonrpc.createManyFixPet': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'pets', 'type': 'Object'}], + 'returns': {'type': 'Array'}, + 'type': 'method', + }, + 'jsonrpc.createManyPet': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'pets', 'type': 'Array'}, {'name': 'pet', 'type': 'Object'}], + 'returns': {'type': 'Array'}, + 'type': 'method', + }, + 'jsonrpc.createPet': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'pet', 'type': 'Object'}], + 'returns': {'type': 'Object'}, + 'type': 'method', + }, + 'jsonrpc.removePet': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'pet', 'type': 'Object'}], + 'returns': {'type': 'Object'}, + 'type': 'method', + }, + 'rpc.describe': {'options': {}, 'params': [], 'returns': {'type': 'Object'}, 'type': 'method'}, + } diff --git a/tests/shared/test_apps/features/objects/test_python_classes.py b/tests/shared/test_apps/features/objects/test_python_classes.py new file mode 100644 index 00000000..e0f9f115 --- /dev/null +++ b/tests/shared/test_apps/features/objects/test_python_classes.py @@ -0,0 +1,209 @@ +# Copyright (c) 2024-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the Cenobit Technologies nor the names of +# its contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +import typing as t + +if t.TYPE_CHECKING: + from requests import Session + + +def test_app_with_pythonclass(session: 'Session', api_url: str) -> None: + rv = session.post( + f'{api_url}/objects/python-classes', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createColor', + 'params': {'color': {'name': 'Blue', 'tag': 'good'}}, + }, + ) + assert rv.status_code == 200 + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': {'id': 1, 'name': 'Blue', 'tag': 'good'}} + + rv = session.post( + f'{api_url}/objects/python-classes', + json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.createColor', 'params': {'color': {'name': 'Red'}}}, + ) + assert rv.status_code == 400 + data = rv.json() + assert data['id'] == 1 + assert data['jsonrpc'] == '2.0' + assert data['error']['code'] == -32602 + assert "missing 1 required positional argument: 'tag'" in data['error']['data']['message'] + assert data['error']['message'] == 'Invalid params' + assert data['error']['name'] == 'InvalidParamsError' + + rv = session.post( + f'{api_url}/objects/python-classes', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createManyColor', + 'params': {'colors': [{'name': 'Blue', 'tag': 'good'}, {'name': 'Red', 'tag': 'bad'}]}, + }, + ) + assert rv.status_code == 200 + assert rv.json() == { + 'id': 1, + 'jsonrpc': '2.0', + 'result': [{'id': 0, 'name': 'Blue', 'tag': 'good'}, {'id': 1, 'name': 'Red', 'tag': 'bad'}], + } + + rv = session.post( + f'{api_url}/objects/python-classes', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createManyColor', + 'params': {'colors': [{'name': 'Blue', 'tag': 'good'}], 'color': {'name': 'Red', 'tag': 'bad'}}, + }, + ) + assert rv.status_code == 200 + assert rv.json() == { + 'id': 1, + 'jsonrpc': '2.0', + 'result': [{'id': 0, 'name': 'Blue', 'tag': 'good'}, {'id': 1, 'name': 'Red', 'tag': 'bad'}], + } + + rv = session.post( + f'{api_url}/objects/python-classes', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createManyColor', + 'params': [ + [{'name': 'Blue', 'tag': 'good'}, {'name': 'Red', 'tag': 'bad'}], + {'name': 'Green', 'tag': 'yay'}, + ], + }, + ) + assert rv.status_code == 200 + assert rv.json() == { + 'id': 1, + 'jsonrpc': '2.0', + 'result': [ + {'id': 0, 'name': 'Blue', 'tag': 'good'}, + {'id': 1, 'name': 'Red', 'tag': 'bad'}, + {'id': 2, 'name': 'Green', 'tag': 'yay'}, + ], + } + + rv = session.post( + f'{api_url}/objects/python-classes', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createManyFixColor', + 'params': {'colors': {'1': {'name': 'Blue', 'tag': 'good'}}}, + }, + ) + assert rv.status_code == 200 + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': [{'id': 1, 'name': 'Blue', 'tag': 'good'}]} + + rv = session.post( + f'{api_url}/objects/python-classes', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.removeColor', + 'params': {'color': {'id': 1, 'name': 'Blue', 'tag': 'good'}}, + }, + ) + assert rv.status_code == 200 + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': {'id': 1, 'name': 'Blue', 'tag': 'good'}} + + rv = session.post( + f'{api_url}/objects/python-classes', + json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removeColor', 'params': {'color': None}}, + ) + assert rv.status_code == 200 + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': None} + + rv = session.post( + f'{api_url}/objects/python-classes', + json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removeColor', 'params': []}, + ) + assert rv.status_code == 200 + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': None} + + rv = session.post( + f'{api_url}/objects/python-classes', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.removeColor', + 'params': {'color': {'id': 100, 'name': 'Blue', 'tag': 'good'}}, + }, + ) + assert rv.status_code == 500 + assert rv.json() == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32000, + 'data': {'color_id': 100, 'reason': 'The color with an ID greater than 10 does not exist.'}, + 'message': 'Server error', + 'name': 'ServerError', + }, + } + + +def test_app_system_describe(session: 'Session', api_url: str) -> None: + rv = session.post(f'{api_url}/objects/python-classes', json={'id': 1, 'jsonrpc': '2.0', 'method': 'rpc.describe'}) + data = rv.json() + assert data['id'] == 1 + assert data['jsonrpc'] == '2.0' + assert data['result']['name'] == 'Flask-JSONRPC' + assert data['result']['version'] == '2.0' + assert data['result']['servers'] is not None + assert 'url' in data['result']['servers'][0] + assert data['result']['methods'] == { + 'jsonrpc.createColor': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'color', 'type': 'Object'}], + 'returns': {'type': 'Object'}, + 'type': 'method', + }, + 'jsonrpc.createManyColor': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'colors', 'type': 'Array'}, {'name': 'color', 'type': 'Object'}], + 'returns': {'type': 'Array'}, + 'type': 'method', + }, + 'jsonrpc.createManyFixColor': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'colors', 'type': 'Object'}], + 'returns': {'type': 'Array'}, + 'type': 'method', + }, + 'jsonrpc.removeColor': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'color', 'type': 'Object'}], + 'returns': {'type': 'Object'}, + 'type': 'method', + }, + 'rpc.describe': {'options': {}, 'params': [], 'returns': {'type': 'Object'}, 'type': 'method'}, + } diff --git a/tests/shared/test_apps/features/objects/test_python_dataclasses.py b/tests/shared/test_apps/features/objects/test_python_dataclasses.py new file mode 100644 index 00000000..bb1025b6 --- /dev/null +++ b/tests/shared/test_apps/features/objects/test_python_dataclasses.py @@ -0,0 +1,211 @@ +# Copyright (c) 2024-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the Cenobit Technologies nor the names of +# its contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +import typing as t + +if t.TYPE_CHECKING: + from requests import Session + + +def test_app_with_dataclass(session: 'Session', api_url: str) -> None: + rv = session.post( + f'{api_url}/objects/python-dataclasses', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createCar', + 'params': {'car': {'name': 'Fusca', 'tag': 'blue'}}, + }, + ) + assert rv.status_code == 200 + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': {'id': 1, 'name': 'Fusca', 'tag': 'blue'}} + + rv = session.post( + f'{api_url}/objects/python-dataclasses', + json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.createCar', 'params': {'car': {'name': 'Fusca'}}}, + ) + assert rv.status_code == 400 + data = rv.json() + assert data['id'] == 1 + assert data['jsonrpc'] == '2.0' + assert data['error']['code'] == -32602 + assert "missing 1 required positional argument: 'tag'" in data['error']['data']['message'] + assert data['error']['message'] == 'Invalid params' + assert data['error']['name'] == 'InvalidParamsError' + + rv = session.post( + f'{api_url}/objects/python-dataclasses', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createManyCar', + 'params': {'cars': [{'name': 'Fusca', 'tag': 'blue'}, {'name': 'Kombi', 'tag': 'yellow'}]}, + }, + ) + assert rv.status_code == 200 + assert rv.json() == { + 'id': 1, + 'jsonrpc': '2.0', + 'result': [{'id': 0, 'name': 'Fusca', 'tag': 'blue'}, {'id': 1, 'name': 'Kombi', 'tag': 'yellow'}], + } + + rv = session.post( + f'{api_url}/objects/python-dataclasses', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createManyCar', + 'params': {'cars': [{'name': 'Fusca', 'tag': 'blue'}], 'car': {'name': 'Kombi', 'tag': 'yellow'}}, + }, + ) + assert rv.status_code == 200 + assert rv.json() == { + 'id': 1, + 'jsonrpc': '2.0', + 'result': [{'id': 0, 'name': 'Fusca', 'tag': 'blue'}, {'id': 1, 'name': 'Kombi', 'tag': 'yellow'}], + } + + rv = session.post( + f'{api_url}/objects/python-dataclasses', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createManyCar', + 'params': [ + [{'name': 'Fusca', 'tag': 'blue'}, {'name': 'Kombi', 'tag': 'yellow'}], + {'name': 'Gol', 'tag': 'white'}, + ], + }, + ) + assert rv.status_code == 200 + assert rv.json() == { + 'id': 1, + 'jsonrpc': '2.0', + 'result': [ + {'id': 0, 'name': 'Fusca', 'tag': 'blue'}, + {'id': 1, 'name': 'Kombi', 'tag': 'yellow'}, + {'id': 2, 'name': 'Gol', 'tag': 'white'}, + ], + } + + rv = session.post( + f'{api_url}/objects/python-dataclasses', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.createManyFixCar', + 'params': {'cars': {'1': {'name': 'Fusca', 'tag': 'blue'}}}, + }, + ) + assert rv.status_code == 200 + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': [{'id': 1, 'name': 'Fusca', 'tag': 'blue'}]} + + rv = session.post( + f'{api_url}/objects/python-dataclasses', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.removeCar', + 'params': {'car': {'id': 1, 'name': 'Fusca', 'tag': 'blue'}}, + }, + ) + assert rv.status_code == 200 + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': {'id': 1, 'name': 'Fusca', 'tag': 'blue'}} + + rv = session.post( + f'{api_url}/objects/python-dataclasses', + json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removeCar', 'params': {'car': None}}, + ) + assert rv.status_code == 200 + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': None} + + rv = session.post( + f'{api_url}/objects/python-dataclasses', + json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removeCar', 'params': []}, + ) + assert rv.status_code == 200 + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': None} + + rv = session.post( + f'{api_url}/objects/python-dataclasses', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.removeCar', + 'params': {'car': {'id': 100, 'name': 'Fusca', 'tag': 'blue'}}, + }, + ) + assert rv.status_code == 500 + assert rv.json() == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32000, + 'data': {'car_id': 100, 'reason': 'The car with an ID greater than 10 does not exist.'}, + 'message': 'Server error', + 'name': 'ServerError', + }, + } + + +def test_app_system_describe(session: 'Session', api_url: str) -> None: + rv = session.post( + f'{api_url}/objects/python-dataclasses', json={'id': 1, 'jsonrpc': '2.0', 'method': 'rpc.describe'} + ) + data = rv.json() + assert data['id'] == 1 + assert data['jsonrpc'] == '2.0' + assert data['result']['name'] == 'Flask-JSONRPC' + assert data['result']['version'] == '2.0' + assert data['result']['servers'] is not None + assert 'url' in data['result']['servers'][0] + assert data['result']['methods'] == { + 'jsonrpc.createCar': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'car', 'type': 'Object'}], + 'returns': {'type': 'Object'}, + 'type': 'method', + }, + 'jsonrpc.createManyCar': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'cars', 'type': 'Array'}, {'name': 'car', 'type': 'Object'}], + 'returns': {'type': 'Array'}, + 'type': 'method', + }, + 'jsonrpc.createManyFixCar': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'cars', 'type': 'Object'}], + 'returns': {'type': 'Array'}, + 'type': 'method', + }, + 'jsonrpc.removeCar': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'car', 'type': 'Object'}], + 'returns': {'type': 'Object'}, + 'type': 'method', + }, + 'rpc.describe': {'options': {}, 'params': [], 'returns': {'type': 'Object'}, 'type': 'method'}, + } diff --git a/tests/shared/test_apps/features/test_class_apps.py b/tests/shared/test_apps/features/test_class_apps.py new file mode 100644 index 00000000..a1c5c125 --- /dev/null +++ b/tests/shared/test_apps/features/test_class_apps.py @@ -0,0 +1,70 @@ +# Copyright (c) 2024-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the Cenobit Technologies nor the names of +# its contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +import typing as t + +if t.TYPE_CHECKING: + from requests import Session + + +def test_app_class(session: 'Session', api_url: str) -> None: + rv = session.post(f'{api_url}/class-apps', json={'id': 1, 'jsonrpc': '2.0', 'method': 'classapp.index'}) + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Flask JSON-RPC'} + assert rv.status_code == 200 + + rv = session.post( + f'{api_url}/class-apps', json={'id': 1, 'jsonrpc': '2.0', 'method': 'greeting', 'params': ['Python']} + ) + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Python'} + assert rv.status_code == 200 + + rv = session.post( + f'{api_url}/class-apps', json={'id': 1, 'jsonrpc': '2.0', 'method': 'hello', 'params': {'name': 'Flask'}} + ) + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Flask'} + assert rv.status_code == 200 + + rv = session.post( + f'{api_url}/class-apps', json={'id': 1, 'jsonrpc': '2.0', 'method': 'echo', 'params': ['Python', 1]} + ) + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': 'Python'} + assert rv.status_code == 200 + + rv = session.post(f'{api_url}/class-apps', json={'jsonrpc': '2.0', 'method': 'notify', 'params': ['Python']}) + assert rv.status_code == 204 + + rv = session.post(f'{api_url}/class-apps', json={'id': 1, 'jsonrpc': '2.0', 'method': 'fails', 'params': [13]}) + assert rv.json() == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32000, + 'data': {'message': 'number is odd'}, + 'message': 'Server error', + 'name': 'ServerError', + }, + } + assert rv.status_code == 500 diff --git a/tests/shared/test_apps/features/test_decorators.py b/tests/shared/test_apps/features/test_decorators.py new file mode 100644 index 00000000..21810d26 --- /dev/null +++ b/tests/shared/test_apps/features/test_decorators.py @@ -0,0 +1,92 @@ +# Copyright (c) 2024-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the Cenobit Technologies nor the names of +# its contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +import typing as t + +if t.TYPE_CHECKING: + from requests import Session + + +def test_app_decorators(session: 'Session', api_url: str) -> None: + data = {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.decorator', 'params': ['Python']} + rv = session.post(f'{api_url}/decorators', json=data) + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Python from decorator, ;)'} + assert rv.status_code == 200 + + data = {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.decorator', 'params': 'Python'} + rv = session.post(f'{api_url}/decorators', json=data) + assert rv.json() == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32602, + 'data': {'message': 'Parameter structures are by-position (list) or by-name (dict): Python'}, + 'message': 'Invalid params', + 'name': 'InvalidParamsError', + }, + } + assert rv.status_code == 400 + + data = {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.decorator', 'params': [1]} + rv = session.post(f'{api_url}/decorators', json=data) + assert rv.json() == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32602, + 'data': {'message': 'argument "string" (int) is not an instance of str'}, + 'message': 'Invalid params', + 'name': 'InvalidParamsError', + }, + } + assert rv.status_code == 400 + + +def test_app_decorators_wrapped(session: 'Session', api_url: str) -> None: + data = {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.wrappedDecorator', 'params': ['Python']} + rv = session.post(f'{api_url}/decorators', json=data) + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Python from decorator, ;)'} + assert rv.status_code == 200 + + data = {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.wrappedDecorator', 'params': 'Python'} + rv = session.post(f'{api_url}/decorators', json=data) + assert rv.json() == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32602, + 'data': {'message': 'Parameter structures are by-position (list) or by-name (dict): Python'}, + 'message': 'Invalid params', + 'name': 'InvalidParamsError', + }, + } + assert rv.status_code == 400 + + # XXX: Typeguard does not instrument wrapped functions + data = {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.wrappedDecorator', 'params': [1]} + rv = session.post(f'{api_url}/decorators', json=data) + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello 1 from decorator, ;)'} + assert rv.status_code == 200 diff --git a/tests/shared/test_apps/features/test_error_handlers.py b/tests/shared/test_apps/features/test_error_handlers.py new file mode 100644 index 00000000..85eb048b --- /dev/null +++ b/tests/shared/test_apps/features/test_error_handlers.py @@ -0,0 +1,62 @@ +# Copyright (c) 2024-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the Cenobit Technologies nor the names of +# its contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +import typing as t + +if t.TYPE_CHECKING: + from requests import Session + + +def test_fails_with_custom_exception(session: 'Session', api_url: str) -> None: + data = {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.failsWithCustomException', 'params': ['Python']} + rv = session.post(f'{api_url}/error-handlers', json=data) + assert rv.json() == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32000, + 'data': {'code': '0001', 'message': 'It is a custom exception'}, + 'message': 'Server error', + 'name': 'ServerError', + }, + } + assert rv.status_code == 500 + + +def test_fails_with_custom_exception_not_registered(session: 'Session', api_url: str) -> None: + data = {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.failsWithCustomExceptionNotRegistered', 'params': ['Python']} + rv = session.post(f'{api_url}/error-handlers', json=data) + assert rv.json() == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32000, + 'data': {'message': 'example of fail with custom exception that will not be handled'}, + 'message': 'Server error', + 'name': 'ServerError', + }, + } + assert rv.status_code == 500 diff --git a/tests/shared/test_apps/features/test_jsonrpc_basic.py b/tests/shared/test_apps/features/test_jsonrpc_basic.py new file mode 100644 index 00000000..c48c1ec0 --- /dev/null +++ b/tests/shared/test_apps/features/test_jsonrpc_basic.py @@ -0,0 +1,412 @@ +# Copyright (c) 2024-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the Cenobit Technologies nor the names of +# its contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +import typing as t + +if t.TYPE_CHECKING: + from requests import Session + + +def test_app_echo(session: 'Session', api_url: str) -> None: + rv = session.post( + f'{api_url}/jsonrpc-basic', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.echo', 'params': ['Python']} + ) + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': 'Python'} + assert rv.status_code == 200 + + rv = session.post( + f'{api_url}/jsonrpc-basic', + json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.echo', 'params': {'string': 'Flask'}}, + ) + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': 'Flask'} + assert rv.status_code == 200 + + rv = session.post( + f'{api_url}/jsonrpc-basic', + json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.echo', 'params': {'string': None}}, + ) + json_data = rv.json() + assert json_data['id'] == 1 + assert json_data['jsonrpc'] == '2.0' + assert json_data['error']['code'] == -32602 + assert "missing 1 required positional argument: 'string'" in json_data['error']['data']['message'] + assert json_data['error']['message'] == 'Invalid params' + assert json_data['error']['name'] == 'InvalidParamsError' + assert rv.status_code == 400 + + +def test_app_echo_raise_invalid_params_error(session: 'Session', api_url: str) -> None: + rv = session.post( + f'{api_url}/jsonrpc-basic', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.echo', 'params': 'Wrong'} + ) + assert rv.json() == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32602, + 'data': {'message': 'Parameter structures are by-position (list) or by-name (dict): Wrong'}, + 'message': 'Invalid params', + 'name': 'InvalidParamsError', + }, + } + assert rv.status_code == 400 + + rv = session.post( + f'{api_url}/jsonrpc-basic', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.echo', 'params': [1]} + ) + assert rv.json() == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32602, + 'data': {'message': 'argument "string" (int) is not an instance of str'}, + 'message': 'Invalid params', + 'name': 'InvalidParamsError', + }, + } + assert rv.status_code == 400 + + rv = session.post( + f'{api_url}/jsonrpc-basic', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.echo', 'params': {'name': 2}} + ) + json_data = rv.json() + assert json_data['id'] == 1 + assert json_data['jsonrpc'] == '2.0' + assert json_data['error']['code'] == -32602 + assert "missing 1 required positional argument: 'string'" in json_data['error']['data']['message'] + assert json_data['error']['message'] == 'Invalid params' + assert json_data['error']['name'] == 'InvalidParamsError' + assert rv.status_code == 400 + + rv = session.post(f'{api_url}/jsonrpc-basic', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.echo'}) + json_data = rv.json() + assert json_data['id'] == 1 + assert json_data['jsonrpc'] == '2.0' + assert json_data['error']['code'] == -32602 + assert "missing 1 required positional argument: 'string'" in json_data['error']['data']['message'] + assert json_data['error']['message'] == 'Invalid params' + assert json_data['error']['name'] == 'InvalidParamsError' + assert rv.status_code == 400 + + +def test_app_notify(session: 'Session', api_url: str) -> None: + rv = session.post(f'{api_url}/jsonrpc-basic', json={'jsonrpc': '2.0', 'method': 'jsonrpc.notify'}) + assert rv.text == '' + assert rv.status_code == 204 + + rv = session.post( + f'{api_url}/jsonrpc-basic', json={'jsonrpc': '2.0', 'method': 'jsonrpc.notify', 'params': ['Some string']} + ) + assert rv.text == '' + assert rv.status_code == 204 + + +def test_app_not_allow_notify(session: 'Session', api_url: str) -> None: + rv = session.post(f'{api_url}/jsonrpc-basic', json={'jsonrpc': '2.0', 'method': 'jsonrpc.not_allow_notify'}) + assert rv.json() == { + 'error': { + 'code': -32600, + 'data': { + 'message': "The method 'jsonrpc.not_allow_notify' doesn't allow Notification Request " + "object (without an 'id' member)" + }, + 'message': 'Invalid Request', + 'name': 'InvalidRequestError', + }, + 'id': None, + 'jsonrpc': '2.0', + } + assert rv.status_code == 400 + + rv = session.post( + f'{api_url}/jsonrpc-basic', + json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.not_allow_notify', 'params': ['Some string']}, + ) + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': 'Not allow notify'} + assert rv.status_code == 200 + + +def test_app_no_return(session: 'Session', api_url: str) -> None: + rv = session.post(f'{api_url}/jsonrpc-basic', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.noReturn'}) + assert rv.json() == { + 'error': {'code': -32000, 'data': {'message': 'no return'}, 'message': 'Server error', 'name': 'ServerError'}, + 'id': 1, + 'jsonrpc': '2.0', + } + assert rv.status_code == 500 + + +def test_app_fails(session: 'Session', api_url: str) -> None: + rv = session.post( + f'{api_url}/jsonrpc-basic', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.fails', 'params': [2]} + ) + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': 2} + assert rv.status_code == 200 + + rv = session.post( + f'{api_url}/jsonrpc-basic', json={'id': '1', 'jsonrpc': '2.0', 'method': 'jsonrpc.fails', 'params': [1]} + ) + assert rv.json() == { + 'id': '1', + 'jsonrpc': '2.0', + 'error': { + 'code': -32000, + 'data': {'message': 'number is odd'}, + 'message': 'Server error', + 'name': 'ServerError', + }, + } + assert rv.status_code == 500 + + +def test_app_strange_echo(session: 'Session', api_url: str) -> None: + data = { + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.strangeEcho', + 'params': ['string', {'a': 1}, ['a', 'b', 'c'], 23, 'Flask'], + } + rv = session.post(f'{api_url}/jsonrpc-basic', json=data) + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': ['string', {'a': 1}, ['a', 'b', 'c'], 23, 'Flask']} + assert rv.status_code == 200 + + data = { + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.strangeEcho', + 'params': ['string', {'a': 1}, ['a', 'b', 'c'], 23], + } + rv = session.post(f'{api_url}/jsonrpc-basic', json=data) + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': ['string', {'a': 1}, ['a', 'b', 'c'], 23, 'Default']} + assert rv.status_code == 200 + + +def test_app_sum(session: 'Session', api_url: str) -> None: + data = {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.sum', 'params': [1, 3]} + rv = session.post(f'{api_url}/jsonrpc-basic', json=data) + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': 4} + assert rv.status_code == 200 + + data = {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.sum', 'params': [0.5, 1.5]} + rv = session.post(f'{api_url}/jsonrpc-basic', json=data) + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': 2.0} + assert rv.status_code == 200 + + data = {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.sum', 'params': {'a': None, 'b': None}} + rv = session.post(f'{api_url}/jsonrpc-basic', json=data) + json_data = rv.json() + assert json_data['id'] == 1 + assert json_data['jsonrpc'] == '2.0' + assert json_data['error']['code'] == -32602 + assert "missing 2 required positional arguments: 'a' and 'b'" in json_data['error']['data']['message'] + assert json_data['error']['message'] == 'Invalid params' + assert json_data['error']['name'] == 'InvalidParamsError' + assert rv.status_code == 400 + + +def test_app_return_status_code(session: 'Session', api_url: str) -> None: + rv = session.post( + f'{api_url}/jsonrpc-basic', + json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.returnStatusCode', 'params': ['OK']}, + ) + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': 'Status Code OK'} + assert rv.status_code == 201 + + +def test_app_return_headers(session: 'Session', api_url: str) -> None: + rv = session.post( + f'{api_url}/jsonrpc-basic', + json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.returnHeaders', 'params': ['OK']}, + ) + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': 'Headers OK'} + assert rv.status_code == 200 + assert ('X-JSONRPC', '1') in list(rv.headers.items()) + + +def test_app_return_status_code_and_headers(session: 'Session', api_url: str) -> None: + rv = session.post( + f'{api_url}/jsonrpc-basic', + json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.returnStatusCodeAndHeaders', 'params': ['OK']}, + ) + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': 'Status Code and Headers OK'} + assert rv.status_code == 400 + assert ('X-JSONRPC', '1') in list(rv.headers.items()) + + +def test_app_with_rcp_batch(session: 'Session', api_url: str) -> None: + rv = session.post(f'{api_url}/jsonrpc-basic', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting'}) + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Flask JSON-RPC'} + assert rv.status_code == 200 + + rv = session.post( + f'{api_url}/jsonrpc-basic', + json=[ + {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['Python']}, + {'id': 2, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['Flask']}, + {'id': 3, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['JSON-RCP']}, + ], + ) + assert rv.json() == [ + {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Python'}, + {'id': 2, 'jsonrpc': '2.0', 'result': 'Hello Flask'}, + {'id': 3, 'jsonrpc': '2.0', 'result': 'Hello JSON-RCP'}, + ] + assert rv.status_code == 200 + + rv = session.post( + f'{api_url}/jsonrpc-basic', + json=[ + {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['Python']}, + {'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['Flask']}, + {'id': 3, 'jsonrpc': '2.0', 'params': ['Flask']}, + {'id': 4, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['JSON-RCP']}, + ], + ) + assert rv.json() == [ + {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Python'}, + { + 'id': 3, + 'jsonrpc': '2.0', + 'error': { + 'code': -32600, + 'data': {'message': "Invalid JSON: {'id': 3, 'jsonrpc': '2.0', 'params': ['Flask']}"}, + 'message': 'Invalid Request', + 'name': 'InvalidRequestError', + }, + }, + {'id': 4, 'jsonrpc': '2.0', 'result': 'Hello JSON-RCP'}, + ] + assert rv.status_code == 200 + + rv = session.post(f'{api_url}/jsonrpc-basic', json={'id': 2, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting'}) + assert rv.json() == {'id': 2, 'jsonrpc': '2.0', 'result': 'Hello Flask JSON-RPC'} + assert rv.status_code == 200 + + +def test_app_system_describe(session: 'Session', api_url: str) -> None: + rv = session.post(f'{api_url}/jsonrpc-basic', json={'id': 1, 'jsonrpc': '2.0', 'method': 'rpc.describe'}) + data = rv.json() + assert data['id'] == 1 + assert data['jsonrpc'] == '2.0' + assert data['result']['name'] == 'Flask-JSONRPC' + assert data['result']['version'] == '2.0' + assert data['result']['servers'] is not None + assert 'url' in data['result']['servers'][0] + assert data['result']['methods'] == { + 'jsonrpc.echo': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'string', 'type': 'String'}, {'name': '_some', 'type': 'Object'}], + 'returns': {'type': 'String'}, + 'type': 'method', + }, + 'jsonrpc.fails': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'n', 'type': 'Number'}], + 'returns': {'type': 'Number'}, + 'type': 'method', + }, + 'jsonrpc.greeting': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'name', 'type': 'String'}], + 'returns': {'type': 'String'}, + 'type': 'method', + }, + 'jsonrpc.mixin_not_validate': { + 'options': {'notification': True, 'validate': False}, + 'params': [ + {'name': 's', 'type': 'Object'}, + {'name': 't', 'type': 'Number'}, + {'name': 'u', 'type': 'Object'}, + {'name': 'v', 'type': 'String'}, + {'name': 'x', 'type': 'Object'}, + {'name': 'z', 'type': 'Object'}, + ], + 'returns': {'type': 'Object'}, + 'type': 'method', + }, + 'jsonrpc.noReturn': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': '_string', 'type': 'String'}], + 'returns': {'type': 'Null'}, + 'type': 'method', + }, + 'jsonrpc.not_allow_notify': { + 'options': {'notification': False, 'validate': True}, + 'params': [{'name': '_string', 'type': 'String'}], + 'returns': {'type': 'String'}, + 'type': 'method', + }, + 'jsonrpc.not_validate': { + 'options': {'notification': True, 'validate': False}, + 'params': [{'name': 's', 'type': 'Object'}], + 'returns': {'type': 'Object'}, + 'type': 'method', + }, + 'jsonrpc.notify': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': '_string', 'type': 'String'}], + 'returns': {'type': 'Null'}, + 'type': 'method', + }, + 'jsonrpc.returnHeaders': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 's', 'type': 'String'}], + 'returns': {'type': 'Array'}, + 'type': 'method', + }, + 'jsonrpc.returnStatusCode': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 's', 'type': 'String'}], + 'returns': {'type': 'Array'}, + 'type': 'method', + }, + 'jsonrpc.returnStatusCodeAndHeaders': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 's', 'type': 'String'}], + 'returns': {'type': 'Array'}, + 'type': 'method', + }, + 'jsonrpc.strangeEcho': { + 'options': {'notification': True, 'validate': True}, + 'params': [ + {'name': 'string', 'type': 'String'}, + {'name': 'omg', 'type': 'Object'}, + {'name': 'wtf', 'type': 'Array'}, + {'name': 'nowai', 'type': 'Number'}, + {'name': 'yeswai', 'type': 'String'}, + ], + 'returns': {'type': 'Array'}, + 'type': 'method', + }, + 'jsonrpc.sum': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'a', 'type': 'Number'}, {'name': 'b', 'type': 'Number'}], + 'returns': {'type': 'Number'}, + 'type': 'method', + }, + 'rpc.describe': {'options': {}, 'params': [], 'returns': {'type': 'Object'}, 'type': 'method'}, + } + assert rv.status_code == 200 diff --git a/tests/shared/test_apps/features/types/__init__.py b/tests/shared/test_apps/features/types/__init__.py new file mode 100644 index 00000000..6023f811 --- /dev/null +++ b/tests/shared/test_apps/features/types/__init__.py @@ -0,0 +1,26 @@ +# Copyright (c) 2024-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the Cenobit Technologies nor the names of +# its contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. diff --git a/tests/shared/test_apps/features/types/test_python_stds.py b/tests/shared/test_apps/features/types/test_python_stds.py new file mode 100644 index 00000000..43e5c416 --- /dev/null +++ b/tests/shared/test_apps/features/types/test_python_stds.py @@ -0,0 +1,440 @@ +# Copyright (c) 2024-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the Cenobit Technologies nor the names of +# its contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +import typing as t + +if t.TYPE_CHECKING: + from requests import Session + + +def test_bool_type(session: 'Session', api_url: str) -> None: + rv = session.post( + f'{api_url}/types/python-stds', json={'jsonrpc': '2.0', 'method': 'jsonrpc.boolType', 'params': [True], 'id': 1} + ) + assert rv.json() == {'jsonrpc': '2.0', 'result': True, 'id': 1} + + +def test_str_type(session: 'Session', api_url: str) -> None: + rv = session.post( + f'{api_url}/types/python-stds', + json={'jsonrpc': '2.0', 'method': 'jsonrpc.strType', 'params': ['Hello'], 'id': 1}, + ) + assert rv.json() == {'jsonrpc': '2.0', 'result': 'Hello', 'id': 1} + + +def test_bytes_type(session: 'Session', api_url: str) -> None: + rv = session.post( + f'{api_url}/types/python-stds', + json={'jsonrpc': '2.0', 'method': 'jsonrpc.bytesType', 'params': ['Hello'], 'id': 1}, + ) + assert rv.json() == {'jsonrpc': '2.0', 'result': 'Hello', 'id': 1} + + +def test_bytearray_type(session: 'Session', api_url: str) -> None: + rv = session.post( + f'{api_url}/types/python-stds', + json={'jsonrpc': '2.0', 'method': 'jsonrpc.bytearrayType', 'params': ['Hello'], 'id': 1}, + ) + assert rv.json() == {'jsonrpc': '2.0', 'result': 'Hello', 'id': 1} + + +def test_int_type(session: 'Session', api_url: str) -> None: + rv = session.post( + f'{api_url}/types/python-stds', json={'jsonrpc': '2.0', 'method': 'jsonrpc.intType', 'params': [42], 'id': 1} + ) + assert rv.json() == {'jsonrpc': '2.0', 'result': 42, 'id': 1} + + +def test_float_type(session: 'Session', api_url: str) -> None: + rv = session.post( + f'{api_url}/types/python-stds', + json={'jsonrpc': '2.0', 'method': 'jsonrpc.floatType', 'params': [3.14], 'id': 1}, + ) + assert rv.json() == {'jsonrpc': '2.0', 'result': 3.14, 'id': 1} + + +def test_enum_type(session: 'Session', api_url: str) -> None: + rv = session.post( + f'{api_url}/types/python-stds', json={'jsonrpc': '2.0', 'method': 'jsonrpc.intEnumType', 'params': [1], 'id': 1} + ) + assert rv.json() == {'jsonrpc': '2.0', 'result': 1, 'id': 1} + + +def test_decimal_type(session: 'Session', api_url: str) -> None: + rv = session.post( + f'{api_url}/types/python-stds', + json={'jsonrpc': '2.0', 'method': 'jsonrpc.decimalType', 'params': [1.5], 'id': 1}, + ) + assert rv.json() == {'jsonrpc': '2.0', 'result': '1.5', 'id': 1} + + +def test_list_type(session: 'Session', api_url: str) -> None: + rv = session.post( + f'{api_url}/types/python-stds', + json={'jsonrpc': '2.0', 'method': 'jsonrpc.listType', 'params': [[1, 2, 3]], 'id': 1}, + ) + assert rv.json() == {'jsonrpc': '2.0', 'result': [1, 2, 3], 'id': 1} + + +def test_tuple_type(session: 'Session', api_url: str) -> None: + rv = session.post( + f'{api_url}/types/python-stds', + json={'jsonrpc': '2.0', 'method': 'jsonrpc.tupleType', 'params': [[1, 2]], 'id': 1}, + ) + assert rv.json() == {'jsonrpc': '2.0', 'result': [1, 2], 'id': 1} + + +def test_namedtuple_type(session: 'Session', api_url: str) -> None: + rv = session.post( + f'{api_url}/types/python-stds', + json={'jsonrpc': '2.0', 'method': 'jsonrpc.namedtupleType', 'params': [{'name': 'Alice', 'id': 1}], 'id': 1}, + ) + assert rv.json() == {'jsonrpc': '2.0', 'result': ['Alice', 1], 'id': 1} + + +def test_set_type(session: 'Session', api_url: str) -> None: + rv = session.post( + f'{api_url}/types/python-stds', + json={'jsonrpc': '2.0', 'method': 'jsonrpc.setType', 'params': [[1, 2, 3]], 'id': 1}, + ) + assert rv.json() == {'jsonrpc': '2.0', 'result': [1, 2, 3], 'id': 1} + + +def test_frozenset_type(session: 'Session', api_url: str) -> None: + rv = session.post( + f'{api_url}/types/python-stds', + json={'jsonrpc': '2.0', 'method': 'jsonrpc.frozensetType', 'params': [[1, 2, 3]], 'id': 1}, + ) + assert rv.json() == {'jsonrpc': '2.0', 'result': [1, 2, 3], 'id': 1} + + +def test_deque_type(session: 'Session', api_url: str) -> None: + rv = session.post( + f'{api_url}/types/python-stds', + json={'jsonrpc': '2.0', 'method': 'jsonrpc.dequeType', 'params': [[1, 2, 3]], 'id': 1}, + ) + assert rv.json() == {'jsonrpc': '2.0', 'result': [1, 2, 3], 'id': 1} + + +def test_sequence_type(session: 'Session', api_url: str) -> None: + rv = session.post( + f'{api_url}/types/python-stds', + json={'jsonrpc': '2.0', 'method': 'jsonrpc.sequenceType', 'params': [[1, 2, 3]], 'id': 1}, + ) + assert rv.json() == {'jsonrpc': '2.0', 'result': [1, 2, 3], 'id': 1} + + +def test_dict_type(session: 'Session', api_url: str) -> None: + rv = session.post( + f'{api_url}/types/python-stds', + json={'jsonrpc': '2.0', 'method': 'jsonrpc.dictType', 'params': [{'key': 1}], 'id': 1}, + ) + assert rv.json() == {'jsonrpc': '2.0', 'result': {'key': 1}, 'id': 1} + + +def test_typedict_type(session: 'Session', api_url: str) -> None: + rv = session.post( + f'{api_url}/types/python-stds', + json={'jsonrpc': '2.0', 'method': 'jsonrpc.typedDictType', 'params': [{'name': 'Alice', 'id': 1}], 'id': 1}, + ) + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': {'id': 1, 'name': 'Alice'}} + + +def test_optional(session: 'Session', api_url: str) -> None: + rv = session.post( + f'{api_url}/types/python-stds', json={'jsonrpc': '2.0', 'method': 'jsonrpc.optional', 'params': [None], 'id': 1} + ) + assert rv.json() == {'jsonrpc': '2.0', 'result': None, 'id': 1} + + rv = session.post( + f'{api_url}/types/python-stds', json={'jsonrpc': '2.0', 'method': 'jsonrpc.optional', 'params': [1], 'id': 1} + ) + assert rv.json() == {'jsonrpc': '2.0', 'result': 1, 'id': 1} + + +def test_union_with_two_types(session: 'Session', api_url: str) -> None: + rv = session.post( + f'{api_url}/types/python-stds', + json={'jsonrpc': '2.0', 'method': 'jsonrpc.unionWithTwoTypes', 'params': [42], 'id': 1}, + ) + assert rv.json() == { + 'jsonrpc': '2.0', + 'id': 1, + 'error': { + 'code': -32602, + 'data': { + 'message': 'the only type of union that is supported is: typing.Union[T, ' 'None] or typing.Optional[T]' + }, + 'message': 'Invalid params', + 'name': 'InvalidParamsError', + }, + } + + rv = session.post( + f'{api_url}/types/python-stds', + json={'jsonrpc': '2.0', 'method': 'jsonrpc.unionWithTwoTypes', 'params': [3.14], 'id': 1}, + ) + assert rv.json() == { + 'jsonrpc': '2.0', + 'id': 1, + 'error': { + 'code': -32602, + 'data': { + 'message': 'the only type of union that is supported is: typing.Union[T, ' 'None] or typing.Optional[T]' + }, + 'message': 'Invalid params', + 'name': 'InvalidParamsError', + }, + } + + +def test_union_with_two_types_and_none(session: 'Session', api_url: str) -> None: + rv = session.post( + f'{api_url}/types/python-stds', + json={'jsonrpc': '2.0', 'method': 'jsonrpc.unionWithTwoTypesAndNone', 'params': [None], 'id': 1}, + ) + assert rv.json() == {'jsonrpc': '2.0', 'result': None, 'id': 1} + + +def test_literal_type(session: 'Session', api_url: str) -> None: + rv = session.post( + f'{api_url}/types/python-stds', + json={'jsonrpc': '2.0', 'method': 'jsonrpc.literalType', 'params': ['X'], 'id': 1}, + ) + assert rv.json() == {'jsonrpc': '2.0', 'result': 'X', 'id': 1} + + +def test_final_type(session: 'Session', api_url: str) -> None: + rv = session.post( + f'{api_url}/types/python-stds', + json={'jsonrpc': '2.0', 'method': 'jsonrpc.finalType', 'params': ['FinalValue'], 'id': 1}, + ) + assert rv.json() == {'jsonrpc': '2.0', 'result': 'FinalValue', 'id': 1} + + +def test_any_type(session: 'Session', api_url: str) -> None: + rv = session.post( + f'{api_url}/types/python-stds', + json={'jsonrpc': '2.0', 'method': 'jsonrpc.anyType', 'params': [{'key': 'value'}], 'id': 1}, + ) + assert rv.json() == {'jsonrpc': '2.0', 'result': {'key': 'value'}, 'id': 1} + + +def test_none_type(session: 'Session', api_url: str) -> None: + rv = session.post( + f'{api_url}/types/python-stds', json={'jsonrpc': '2.0', 'method': 'jsonrpc.noneType', 'params': [None], 'id': 1} + ) + assert rv.json() == {'jsonrpc': '2.0', 'result': None, 'id': 1} + + +def test_no_return_type(session: 'Session', api_url: str) -> None: + rv = session.post( + f'{api_url}/types/python-stds', + json={'jsonrpc': '2.0', 'method': 'jsonrpc.noReturnType', 'params': ['no return'], 'id': 1}, + ) + assert rv.json() == { + 'jsonrpc': '2.0', + 'id': 1, + 'error': {'code': -32000, 'data': {'message': 'no return'}, 'message': 'Server error', 'name': 'ServerError'}, + } + + +def test_literal_none_type(session: 'Session', api_url: str) -> None: + rv = session.post( + f'{api_url}/types/python-stds', + json={'jsonrpc': '2.0', 'method': 'jsonrpc.literalNoneType', 'params': [None], 'id': 1}, + ) + assert rv.json() == {'jsonrpc': '2.0', 'result': None, 'id': 1} + + +def test_app_system_describe(session: 'Session', api_url: str) -> None: + rv = session.post(f'{api_url}/types/python-stds', json={'id': 1, 'jsonrpc': '2.0', 'method': 'rpc.describe'}) + data = rv.json() + assert data['id'] == 1 + assert data['jsonrpc'] == '2.0' + assert data['result']['name'] == 'Flask-JSONRPC' + assert data['result']['version'] == '2.0' + assert data['result']['servers'] is not None + assert 'url' in data['result']['servers'][0] + assert data['result']['methods'] == { + 'jsonrpc.anyType': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'obj', 'type': 'Object'}], + 'returns': {'type': 'Object'}, + 'type': 'method', + }, + 'jsonrpc.boolType': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'yes', 'type': 'Boolean'}], + 'returns': {'type': 'Boolean'}, + 'type': 'method', + }, + 'jsonrpc.bytearrayType': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'b', 'type': 'String'}], + 'returns': {'type': 'String'}, + 'type': 'method', + }, + 'jsonrpc.bytesType': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'b', 'type': 'String'}], + 'returns': {'type': 'String'}, + 'type': 'method', + }, + 'jsonrpc.decimalType': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'n', 'type': 'Object'}], + 'returns': {'type': 'Object'}, + 'type': 'method', + }, + 'jsonrpc.dequeType': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'd', 'type': 'Array'}], + 'returns': {'type': 'Array'}, + 'type': 'method', + }, + 'jsonrpc.dictType': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'd', 'type': 'Object'}], + 'returns': {'type': 'Object'}, + 'type': 'method', + }, + 'jsonrpc.finalType': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'x', 'type': 'String'}], + 'returns': {'type': 'String'}, + 'type': 'method', + }, + 'jsonrpc.floatType': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'n', 'type': 'Number'}], + 'returns': {'type': 'Number'}, + 'type': 'method', + }, + 'jsonrpc.frozensetType': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 's', 'type': 'Array'}], + 'returns': {'type': 'Array'}, + 'type': 'method', + }, + 'jsonrpc.intEnumType': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'e', 'type': 'Object'}], + 'returns': {'type': 'Object'}, + 'type': 'method', + }, + 'jsonrpc.intType': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'n', 'type': 'Number'}], + 'returns': {'type': 'Number'}, + 'type': 'method', + }, + 'jsonrpc.listType': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'lst', 'type': 'Array'}], + 'returns': {'type': 'Array'}, + 'type': 'method', + }, + 'jsonrpc.literalNoneType': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'x', 'type': 'Null'}], + 'returns': {'type': 'Null'}, + 'type': 'method', + }, + 'jsonrpc.literalType': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'x', 'type': 'String'}], + 'returns': {'type': 'String'}, + 'type': 'method', + }, + 'jsonrpc.namedtupleType': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'tn', 'type': 'Object'}], + 'returns': {'type': 'Array'}, + 'type': 'method', + }, + 'jsonrpc.noReturnType': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 's', 'type': 'String'}], + 'returns': {'type': 'Null'}, + 'type': 'method', + }, + 'jsonrpc.noneType': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'obj', 'type': 'Null'}], + 'returns': {'type': 'Null'}, + 'type': 'method', + }, + 'jsonrpc.optional': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'n', 'type': 'Number'}], + 'returns': {'type': 'Number'}, + 'type': 'method', + }, + 'jsonrpc.sequenceType': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 's', 'type': 'Object'}], + 'returns': {'type': 'Object'}, + 'type': 'method', + }, + 'jsonrpc.setType': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 's', 'type': 'Array'}], + 'returns': {'type': 'Array'}, + 'type': 'method', + }, + 'jsonrpc.strType': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'st', 'type': 'String'}], + 'returns': {'type': 'String'}, + 'type': 'method', + }, + 'jsonrpc.tupleType': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'tn', 'type': 'Array'}], + 'returns': {'type': 'Array'}, + 'type': 'method', + }, + 'jsonrpc.typedDictType': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'user', 'type': 'Object'}], + 'returns': {'type': 'Object'}, + 'type': 'method', + }, + 'jsonrpc.unionWithTwoTypes': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'n', 'type': 'Number'}], + 'returns': {'type': 'Number'}, + 'type': 'method', + }, + 'jsonrpc.unionWithTwoTypesAndNone': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'n', 'type': 'Number'}], + 'returns': {'type': 'Number'}, + 'type': 'method', + }, + 'rpc.describe': {'options': {}, 'params': [], 'returns': {'type': 'Object'}, 'type': 'method'}, + } diff --git a/tests/shared/test_apps/test_app.py b/tests/shared/test_apps/test_app.py new file mode 100644 index 00000000..0562c1ad --- /dev/null +++ b/tests/shared/test_apps/test_app.py @@ -0,0 +1,239 @@ +# Copyright (c) 2020-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the Cenobit Technologies nor the names of +# its contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +import json +import typing as t + +if t.TYPE_CHECKING: + from requests import Session + + +def test_app_greeting(session: 'Session', api_url: str) -> None: + rv = session.post(api_url, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting'}) + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Flask JSON-RPC'} + assert rv.status_code == 200 + + rv = session.post(api_url, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['Python']}) + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Python'} + assert rv.status_code == 200 + + rv = session.post( + api_url, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': {'name': 'Flask'}} + ) + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Flask'} + assert rv.status_code == 200 + + rv = session.post(api_url, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': {'name': 1}}) + assert rv.json() == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32602, + 'data': {'message': 'argument "name" (int) is not an instance of str'}, + 'message': 'Invalid params', + 'name': 'InvalidParamsError', + }, + } + assert rv.status_code == 400 + + +def test_app_greeting_with_different_content_types(session: 'Session', api_url: str) -> None: + rv = session.post( + api_url, + data=json.dumps({'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting'}), + headers={'Content-Type': 'application/json-rpc'}, + ) + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Flask JSON-RPC'} + assert rv.status_code == 200 + + rv = session.post( + api_url, + data=json.dumps({'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['Python']}), + headers={'Content-Type': 'application/jsonrequest'}, + ) + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Python'} + assert rv.status_code == 200 + + rv = session.post( + api_url, + data=json.dumps({'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': {'name': 'Flask'}}), + headers={'Content-Type': 'application/json'}, + ) + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Flask'} + assert rv.status_code == 200 + + +def test_app_greeting_raise_parse_error(session: 'Session', api_url: str) -> None: + rv = session.post(api_url, data={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting'}) + assert rv.json() == { + 'id': None, + 'jsonrpc': '2.0', + 'error': { + 'code': -32700, + 'data': { + 'message': 'Invalid mime type for JSON: application/x-www-form-urlencoded, ' + 'use header Content-Type: application/json' + }, + 'message': 'Parse error', + 'name': 'ParseError', + }, + } + assert rv.status_code == 400 + + rv = session.post( + api_url, + data="{'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting'}", + headers={'Content-Type': 'application/json'}, + ) + assert rv.json() == { + 'id': None, + 'jsonrpc': '2.0', + 'error': { + 'code': -32700, + 'data': {'message': "Invalid JSON: b\"{'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting'}\""}, + 'message': 'Parse error', + 'name': 'ParseError', + }, + } + assert rv.status_code == 400 + + rv = session.post( + api_url, + data="""[ + {'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['Flask'], 'id': '1'}, + {'jsonrpc': '2.0', 'method' + ]""", + headers={'Content-Type': 'application/json'}, + ) + assert rv.json() == { + 'id': None, + 'jsonrpc': '2.0', + 'error': { + 'code': -32700, + 'data': { + 'message': "Invalid JSON: b\"[\\n {'jsonrpc': " + "'2.0', 'method': 'jsonrpc.greeting', 'params': " + "['Flask'], 'id': '1'},\\n " + "{'jsonrpc': '2.0', 'method'\\n " + ']"' + }, + 'message': 'Parse error', + 'name': 'ParseError', + }, + } + assert rv.status_code == 400 + + +def test_app_greeting_raise_invalid_request_error(session: 'Session', api_url: str) -> None: + rv = session.post(api_url, json={'id': 1, 'jsonrpc': '2.0'}) + assert rv.json() == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32600, + 'data': {'message': "Invalid JSON: {'id': 1, 'jsonrpc': '2.0'}"}, + 'message': 'Invalid Request', + 'name': 'InvalidRequestError', + }, + } + assert rv.status_code == 400 + + +def test_app_greeting_raise_invalid_params_error(session: 'Session', api_url: str) -> None: + rv = session.post(api_url, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': 'Wrong'}) + assert rv.json() == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32602, + 'data': {'message': 'Parameter structures are by-position (list) or by-name (dict): Wrong'}, + 'message': 'Invalid params', + 'name': 'InvalidParamsError', + }, + } + assert rv.status_code == 400 + + rv = session.post(api_url, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': [1]}) + assert rv.json() == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32602, + 'data': {'message': 'argument "name" (int) is not an instance of str'}, + 'message': 'Invalid params', + 'name': 'InvalidParamsError', + }, + } + assert rv.status_code == 400 + + rv = session.post(api_url, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': {'name': 2}}) + assert rv.json() == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32602, + 'data': {'message': 'argument "name" (int) is not an instance of str'}, + 'message': 'Invalid params', + 'name': 'InvalidParamsError', + }, + } + assert rv.status_code == 400 + + +def test_app_greeting_raise_method_not_found_error(session: 'Session', api_url: str) -> None: + rv = session.post(api_url, json={'id': 1, 'jsonrpc': '2.0', 'method': 'method-not-found'}) + assert rv.json() == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32601, + 'data': {'message': 'Method not found: method-not-found'}, + 'message': 'Method not found', + 'name': 'MethodNotFoundError', + }, + } + assert rv.status_code == 400 + + +def test_app_system_describe(session: 'Session', api_url: str) -> None: + rv = session.post(api_url, json={'id': 1, 'jsonrpc': '2.0', 'method': 'rpc.describe'}) + data = rv.json() + assert data['id'] == 1 + assert data['jsonrpc'] == '2.0' + assert data['result']['name'] == 'Flask-JSONRPC' + assert data['result']['version'] == '2.0' + assert data['result']['servers'] is not None + assert 'url' in data['result']['servers'][0] + assert data['result']['methods'] == { + 'jsonrpc.greeting': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'name', 'type': 'String'}], + 'returns': {'type': 'String'}, + 'type': 'method', + }, + 'rpc.describe': {'options': {}, 'params': [], 'returns': {'type': 'Object'}, 'type': 'method'}, + } + assert rv.status_code == 200 diff --git a/tests/shared/test_apps/test_async_app.py b/tests/shared/test_apps/test_async_app.py new file mode 100644 index 00000000..408def8b --- /dev/null +++ b/tests/shared/test_apps/test_async_app.py @@ -0,0 +1,504 @@ +# Copyright (c) 2020-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the Cenobit Technologies nor the names of +# its contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +import json +import typing as t + +import pytest + +if t.TYPE_CHECKING: + from requests import Session + +pytest.importorskip('asgiref') + + +def test_app_greeting(async_session: 'Session', api_url: str) -> None: + rv = async_session.post(api_url, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting'}) + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Flask JSON-RPC'} + assert rv.status_code == 200 + + rv = async_session.post( + api_url, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['Python']} + ) + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Python'} + assert rv.status_code == 200 + + rv = async_session.post( + api_url, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': {'name': 'Flask'}} + ) + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Flask'} + assert rv.status_code == 200 + + rv = async_session.post( + api_url, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': {'name': 1}} + ) + assert rv.json() == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32602, + 'data': {'message': 'argument "name" (int) is not an instance of str'}, + 'message': 'Invalid params', + 'name': 'InvalidParamsError', + }, + } + assert rv.status_code == 400 + + +def test_app_greeting_with_different_content_types(async_session: 'Session', api_url: str) -> None: + rv = async_session.post( + api_url, + data=json.dumps({'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting'}), + headers={'Content-Type': 'application/json-rpc'}, + ) + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Flask JSON-RPC'} + assert rv.status_code == 200 + + rv = async_session.post( + api_url, + data=json.dumps({'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['Python']}), + headers={'Content-Type': 'application/jsonrequest'}, + ) + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Python'} + assert rv.status_code == 200 + + rv = async_session.post( + api_url, + data=json.dumps({'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': {'name': 'Flask'}}), + headers={'Content-Type': 'application/json'}, + ) + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Flask'} + assert rv.status_code == 200 + + +def test_app_greeting_raise_parse_error(async_session: 'Session', api_url: str) -> None: + rv = async_session.post(api_url, data={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting'}) + assert rv.json() == { + 'id': None, + 'jsonrpc': '2.0', + 'error': { + 'code': -32700, + 'data': { + 'message': 'Invalid mime type for JSON: application/x-www-form-urlencoded, ' + 'use header Content-Type: application/json' + }, + 'message': 'Parse error', + 'name': 'ParseError', + }, + } + assert rv.status_code == 400 + + rv = async_session.post( + api_url, + data="{'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting'}", + headers={'Content-Type': 'application/json'}, + ) + assert rv.json() == { + 'id': None, + 'jsonrpc': '2.0', + 'error': { + 'code': -32700, + 'data': {'message': "Invalid JSON: b\"{'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting'}\""}, + 'message': 'Parse error', + 'name': 'ParseError', + }, + } + assert rv.status_code == 400 + + rv = async_session.post( + api_url, + data="""[ + {'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['Flask'], 'id': '1'}, + {'jsonrpc': '2.0', 'method' + ]""", + headers={'Content-Type': 'application/json'}, + ) + assert rv.json() == { + 'id': None, + 'jsonrpc': '2.0', + 'error': { + 'code': -32700, + 'data': { + 'message': "Invalid JSON: b\"[\\n {'jsonrpc': " + "'2.0', 'method': 'jsonrpc.greeting', 'params': " + "['Flask'], 'id': '1'},\\n " + "{'jsonrpc': '2.0', 'method'\\n " + ']"' + }, + 'message': 'Parse error', + 'name': 'ParseError', + }, + } + assert rv.status_code == 400 + + +def test_app_greeting_raise_invalid_request_error(async_session: 'Session', api_url: str) -> None: + rv = async_session.post(api_url, json={'id': 1, 'jsonrpc': '2.0'}) + assert rv.json() == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32600, + 'data': {'message': "Invalid JSON: {'id': 1, 'jsonrpc': '2.0'}"}, + 'message': 'Invalid Request', + 'name': 'InvalidRequestError', + }, + } + assert rv.status_code == 400 + + +def test_app_greeting_raise_invalid_params_error(async_session: 'Session', api_url: str) -> None: + rv = async_session.post(api_url, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': 'Wrong'}) + assert rv.json() == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32602, + 'data': {'message': 'Parameter structures are by-position (list) or by-name (dict): Wrong'}, + 'message': 'Invalid params', + 'name': 'InvalidParamsError', + }, + } + assert rv.status_code == 400 + + rv = async_session.post(api_url, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': [1]}) + assert rv.json() == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32602, + 'data': {'message': 'argument "name" (int) is not an instance of str'}, + 'message': 'Invalid params', + 'name': 'InvalidParamsError', + }, + } + assert rv.status_code == 400 + + rv = async_session.post( + api_url, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': {'name': 2}} + ) + assert rv.json() == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32602, + 'data': {'message': 'argument "name" (int) is not an instance of str'}, + 'message': 'Invalid params', + 'name': 'InvalidParamsError', + }, + } + assert rv.status_code == 400 + + +def test_app_greeting_raise_method_not_found_error(async_session: 'Session', api_url: str) -> None: + rv = async_session.post(api_url, json={'id': 1, 'jsonrpc': '2.0', 'method': 'method-not-found'}) + assert rv.json() == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32601, + 'data': {'message': 'Method not found: method-not-found'}, + 'message': 'Method not found', + 'name': 'MethodNotFoundError', + }, + } + assert rv.status_code == 400 + + +def test_app_echo(async_session: 'Session', api_url: str) -> None: + rv = async_session.post(api_url, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.echo', 'params': ['Python']}) + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': 'Python'} + assert rv.status_code == 200 + + rv = async_session.post( + api_url, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.echo', 'params': {'string': 'Flask'}} + ) + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': 'Flask'} + assert rv.status_code == 200 + + rv = async_session.post( + api_url, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.echo', 'params': {'string': None}} + ) + json_data = rv.json() + assert json_data['id'] == 1 + assert json_data['jsonrpc'] == '2.0' + assert json_data['error']['code'] == -32602 + assert "missing 1 required positional argument: 'string'" in json_data['error']['data']['message'] + assert json_data['error']['message'] == 'Invalid params' + assert json_data['error']['name'] == 'InvalidParamsError' + assert rv.status_code == 400 + + +def test_app_echo_raise_invalid_params_error(async_session: 'Session', api_url: str) -> None: + rv = async_session.post(api_url, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.echo', 'params': 'Wrong'}) + assert rv.json() == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32602, + 'data': {'message': 'Parameter structures are by-position (list) or by-name (dict): Wrong'}, + 'message': 'Invalid params', + 'name': 'InvalidParamsError', + }, + } + assert rv.status_code == 400 + + rv = async_session.post(api_url, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.echo', 'params': [1]}) + assert rv.json() == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32602, + 'data': {'message': 'argument "string" (int) is not an instance of str'}, + 'message': 'Invalid params', + 'name': 'InvalidParamsError', + }, + } + assert rv.status_code == 400 + + rv = async_session.post(api_url, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.echo', 'params': {'name': 2}}) + json_data = rv.json() + assert json_data['id'] == 1 + assert json_data['jsonrpc'] == '2.0' + assert json_data['error']['code'] == -32602 + assert "missing 1 required positional argument: 'string'" in json_data['error']['data']['message'] + assert json_data['error']['message'] == 'Invalid params' + assert json_data['error']['name'] == 'InvalidParamsError' + assert rv.status_code == 400 + + rv = async_session.post(api_url, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.echo'}) + json_data = rv.json() + assert json_data['id'] == 1 + assert json_data['jsonrpc'] == '2.0' + assert json_data['error']['code'] == -32602 + assert "missing 1 required positional argument: 'string'" in json_data['error']['data']['message'] + assert json_data['error']['message'] == 'Invalid params' + assert json_data['error']['name'] == 'InvalidParamsError' + assert rv.status_code == 400 + + +def test_app_notify(async_session: 'Session', api_url: str) -> None: + rv = async_session.post(api_url, json={'jsonrpc': '2.0', 'method': 'jsonrpc.notify'}) + assert rv.text == '' + assert rv.status_code == 204 + + rv = async_session.post(api_url, json={'jsonrpc': '2.0', 'method': 'jsonrpc.notify', 'params': ['Some string']}) + assert rv.text == '' + assert rv.status_code == 204 + + +def test_app_fails(async_session: 'Session', api_url: str) -> None: + rv = async_session.post(api_url, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.fails', 'params': [2]}) + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': 2} + assert rv.status_code == 200 + + rv = async_session.post(api_url, json={'id': '1', 'jsonrpc': '2.0', 'method': 'jsonrpc.fails', 'params': [1]}) + assert rv.json() == { + 'id': '1', + 'jsonrpc': '2.0', + 'error': { + 'code': -32000, + 'data': {'message': 'number is odd'}, + 'message': 'Server error', + 'name': 'ServerError', + }, + } + assert rv.status_code == 500 + + +def test_app_decorators(async_session: 'Session', api_url: str) -> None: + data = {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.decorators', 'params': ['Python']} + rv = async_session.post(api_url, json=data) + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Python from decorator, ;)'} + assert rv.status_code == 200 + + data = {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.decorators', 'params': 'Python'} + rv = async_session.post(api_url, json=data) + assert rv.json() == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32602, + 'data': {'message': 'Parameter structures are by-position (list) or by-name (dict): Python'}, + 'message': 'Invalid params', + 'name': 'InvalidParamsError', + }, + } + assert rv.status_code == 400 + + data = {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.decorators', 'params': [1]} + rv = async_session.post(api_url, json=data) + assert rv.json() == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32602, + 'data': {'message': 'argument "string" (int) is not an instance of str'}, + 'message': 'Invalid params', + 'name': 'InvalidParamsError', + }, + } + assert rv.status_code == 400 + + +def test_app_decorators_wrapped(async_session: 'Session', api_url: str) -> None: + data = {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.wrappedDecorators', 'params': ['Python']} + rv = async_session.post(api_url, json=data) + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Python from decorator, ;)'} + assert rv.status_code == 200 + + data = {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.wrappedDecorators', 'params': 'Python'} + rv = async_session.post(api_url, json=data) + assert rv.json() == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32602, + 'data': {'message': 'Parameter structures are by-position (list) or by-name (dict): Python'}, + 'message': 'Invalid params', + 'name': 'InvalidParamsError', + }, + } + assert rv.status_code == 400 + + # XXX: Typeguard does not instrument wrapped functions + data = {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.wrappedDecorators', 'params': [1]} + rv = async_session.post(api_url, json=data) + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello 1 from decorator, ;)'} + assert rv.status_code == 200 + + +def test_app_with_rcp_batch(async_session: 'Session', api_url: str) -> None: + rv = async_session.post(api_url, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting'}) + assert rv.json() == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Flask JSON-RPC'} + assert rv.status_code == 200 + + rv = async_session.post( + api_url, + json=[ + {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['Python']}, + {'id': 2, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['Flask']}, + {'id': 3, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['JSON-RCP']}, + ], + ) + assert rv.json() == [ + {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Python'}, + {'id': 2, 'jsonrpc': '2.0', 'result': 'Hello Flask'}, + {'id': 3, 'jsonrpc': '2.0', 'result': 'Hello JSON-RCP'}, + ] + assert rv.status_code == 200 + + rv = async_session.post( + api_url, + json=[ + {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['Python']}, + {'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['Flask']}, + {'id': 3, 'jsonrpc': '2.0', 'params': ['Flask']}, + {'id': 4, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['JSON-RCP']}, + ], + ) + assert rv.json() == [ + {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Python'}, + { + 'id': 3, + 'jsonrpc': '2.0', + 'error': { + 'code': -32600, + 'data': {'message': "Invalid JSON: {'id': 3, 'jsonrpc': '2.0', 'params': ['Flask']}"}, + 'message': 'Invalid Request', + 'name': 'InvalidRequestError', + }, + }, + {'id': 4, 'jsonrpc': '2.0', 'result': 'Hello JSON-RCP'}, + ] + assert rv.status_code == 200 + + rv = async_session.post(api_url, json={'id': 2, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting'}) + assert rv.json() == {'id': 2, 'jsonrpc': '2.0', 'result': 'Hello Flask JSON-RPC'} + assert rv.status_code == 200 + + +def test_app_system_describe(async_session: 'Session', api_url: str) -> None: + rv = async_session.post(api_url, json={'id': 1, 'jsonrpc': '2.0', 'method': 'rpc.describe'}) + data = rv.json() + assert data['id'] == 1 + assert data['jsonrpc'] == '2.0' + assert data['result']['name'] == 'Flask-JSONRPC' + assert data['result']['version'] == '2.0' + assert data['result']['servers'] is not None + assert 'url' in data['result']['servers'][0] + assert data['result']['methods'] == { + 'jsonrpc.decorators': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'string', 'type': 'String'}], + 'returns': {'type': 'String'}, + 'type': 'method', + }, + 'jsonrpc.echo': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'string', 'type': 'String'}, {'name': '_some', 'type': 'Object'}], + 'returns': {'type': 'String'}, + 'type': 'method', + }, + 'jsonrpc.fails': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'n', 'type': 'Number'}], + 'returns': {'type': 'Number'}, + 'type': 'method', + }, + 'jsonrpc.failsWithCustomException': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': '_string', 'type': 'String'}], + 'returns': {'type': 'Null'}, + 'type': 'method', + }, + 'jsonrpc.failsWithCustomExceptionNotRegistered': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': '_string', 'type': 'String'}], + 'returns': {'type': 'Null'}, + 'type': 'method', + }, + 'jsonrpc.greeting': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'name', 'type': 'String'}], + 'returns': {'type': 'String'}, + 'type': 'method', + }, + 'jsonrpc.notify': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': '_string', 'type': 'String'}], + 'returns': {'type': 'Null'}, + 'type': 'method', + }, + 'jsonrpc.wrappedDecorators': { + 'options': {'notification': True, 'validate': True}, + 'params': [{'name': 'string', 'type': 'String'}], + 'returns': {'type': 'String'}, + 'type': 'method', + }, + 'rpc.describe': {'options': {}, 'params': [], 'returns': {'type': 'Object'}, 'type': 'method'}, + } + assert rv.status_code == 200 diff --git a/tests/test_apps/app/__init__.py b/tests/test_apps/app/__init__.py index f659a218..6023f811 100644 --- a/tests/test_apps/app/__init__.py +++ b/tests/test_apps/app/__init__.py @@ -1,5 +1,4 @@ -#!/usr/bin/env python -# Copyright (c) 2012-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# Copyright (c) 2024-2024, Cenobit Technologies, Inc. http://cenobit.es/ # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -25,358 +24,3 @@ # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -import os -import typing as t -import functools -from dataclasses import dataclass - -from flask import Flask - -# Python 3.11+ -try: - from typing import Self -except ImportError: # pragma: no cover - from typing_extensions import Self - -from pydantic import BaseModel - -from flask_jsonrpc import JSONRPC - - -class NewColor: - name: str - tag: str - - def __init__(self: Self, name: str, tag: str) -> None: - self.name = name - self.tag = tag - - -class Color(NewColor): - id: int - - def __init__(self: Self, id: int, name: str, tag: str) -> None: - super().__init__(name, tag) - self.id = id - - -class ColorError: - def __init__(self: Self, color_id: int, reason: str) -> None: - self.color_id = color_id - self.reason = reason - - -class ColorException(Exception): - def __init__(self: Self, *args: object) -> None: - super().__init__(*args) - - -class ColorNotFoundException(ColorException): - def __init__(self: Self, message: str, color_error: ColorError) -> None: - super().__init__(message) - self.message = message - self.color_error = color_error - - -@dataclass -class NewCar: - name: str - tag: str - - -@dataclass -class Car(NewCar): - id: int - - -@dataclass -class CarError: - car_id: int - reason: str - - -class CarException(Exception): - def __init__(self: Self, *args: object) -> None: - super().__init__(*args) - - -class CarNotFoundException(CarException): - def __init__(self: Self, message: str, car_error: CarError) -> None: - super().__init__(message) - self.message = message - self.car_error = car_error - - -class NewPet(BaseModel): - name: str - tag: str - - -class Pet(NewPet): - id: int - - -class PetError(BaseModel): - pet_id: int - reason: str - - -class PetException(Exception): - def __init__(self: Self, *args: object) -> None: - super().__init__(*args) - - -class PetNotFoundException(PetException): - def __init__(self: Self, message: str, pet_error: PetError) -> None: - super().__init__(message) - self.message = message - self.pet_error = pet_error - - -class App: - @staticmethod - def index(name: str = 'Flask JSON-RPC') -> str: - return f'Hello {name}' - - @staticmethod - def greeting(name: str = 'Flask JSON-RPC') -> str: - return f'Hello {name}' - - @staticmethod - def hello(name: str = 'Flask JSON-RPC') -> str: - return f'Hello {name}' - - @staticmethod - def echo(string: str, _some: t.Any = None) -> str: # noqa: ANN401 - return string - - @staticmethod - def notify(_string: t.Optional[str] = None) -> None: - pass - - @staticmethod - def not_allow_notify(_string: t.Optional[str] = None) -> str: - return 'Now allow notify' - - @staticmethod - def fails(n: int) -> int: - if n % 2 == 0: - return n - raise ValueError('number is odd') - - -def jsonrpc_decorator(fn: t.Callable[..., str]) -> t.Callable[..., str]: - def decorator(string: str) -> str: - rv = fn(string) - return f'{rv} from decorator, ;)' - - return decorator - - -def jsonrpc_decorator_wrapped(fn: t.Callable[..., str]) -> t.Callable[..., str]: - @functools.wraps(fn) - def decorator(string: str) -> str: - rv = fn(string) - return f'{rv} from decorator, ;)' - - return decorator - - -def create_app(test_config: t.Optional[t.Dict[str, t.Any]] = None) -> Flask: # noqa: C901 pylint: disable=W0612 - """Create and configure an instance of the Flask application.""" - flask_app = Flask('apptest', instance_relative_config=True) - if test_config: - flask_app.config.update(test_config) - - jsonrpc = JSONRPC(flask_app, '/api', enable_web_browsable_api=True) - - @jsonrpc.errorhandler(ColorNotFoundException) - def handle_color_not_found_exc(exc: ColorNotFoundException) -> ColorError: - return exc.color_error - - def handle_pet_not_found_exc(exc: PetNotFoundException) -> PetError: - return exc.pet_error - - jsonrpc.register_error_handler(PetNotFoundException, handle_pet_not_found_exc) - - @jsonrpc.errorhandler(CarNotFoundException) - def handle_car_not_found_exc(exc: CarNotFoundException) -> CarError: - return exc.car_error - - # pylint: disable=W0612 - @jsonrpc.method('jsonrpc.greeting') - def greeting(name: str = 'Flask JSON-RPC') -> str: - return f'Hello {name}' - - # pylint: disable=W0612 - @jsonrpc.method('jsonrpc.echo') - def echo(string: str, _some: t.Any = None) -> str: # noqa: ANN401 - return string - - # pylint: disable=W0612 - @jsonrpc.method('jsonrpc.notify') - def notify(_string: t.Optional[str] = None) -> None: - pass - - # pylint: disable=W0612 - @jsonrpc.method('jsonrpc.not_allow_notify', notification=False) - def not_allow_notify(_string: str = 'None') -> str: - return 'Not allow notify' - - # pylint: disable=W0612 - @jsonrpc.method('jsonrpc.fails') - def fails(n: int) -> int: - if n % 2 == 0: - return n - raise ValueError('number is odd') - - # pylint: disable=W0612 - @jsonrpc.method('jsonrpc.strangeEcho') - def strange_echo( - string: str, omg: t.Dict[str, t.Any], wtf: t.List[str], nowai: int, yeswai: str = 'Default' - ) -> t.List[t.Any]: - return [string, omg, wtf, nowai, yeswai] - - # pylint: disable=W0612 - @jsonrpc.method('jsonrpc.sum') - def sum_(a: t.Union[int, float], b: t.Union[int, float]) -> t.Union[int, float]: - return a + b - - # pylint: disable=W0612 - @jsonrpc.method('jsonrpc.decorators') - @jsonrpc_decorator - def decorators(string: str) -> str: - return f'Hello {string}' - - # pylint: disable=W0612 - @jsonrpc.method('jsonrpc.decoratorsWrapped') - @jsonrpc_decorator_wrapped - def decoratorsWrapped(string: str) -> str: - return f'Hello {string}' - - # pylint: disable=W0612 - @jsonrpc.method('jsonrpc.returnStatusCode') - def return_status_code(s: str) -> t.Tuple[str, int]: - return f'Status Code {s}', 201 - - # pylint: disable=W0612 - @jsonrpc.method('jsonrpc.returnHeaders') - def return_headers(s: str) -> t.Tuple[str, t.Dict[str, t.Any]]: - return f'Headers {s}', {'X-JSONRPC': '1'} - - # pylint: disable=W0612 - @jsonrpc.method('jsonrpc.returnStatusCodeAndHeaders') - def return_status_code_and_headers(s: str) -> t.Tuple[str, int, t.Dict[str, t.Any]]: - return f'Status Code and Headers {s}', 400, {'X-JSONRPC': '1'} - - # pylint: disable=W0612 - @jsonrpc.method('jsonrpc.not_validate', validate=False) - def not_validate(s='Oops!'): # noqa: ANN001,ANN202 - return f'Not validate: {s}' - - # pylint: disable=W0612 - @jsonrpc.method('jsonrpc.mixin_not_validate', validate=False) - def mixin_not_validate(s, t: int, u, v: str, x, z): # noqa: ANN001,ANN202 - return f'Not validate: {s} {t} {u} {v} {x} {z}' - - @jsonrpc.method('jsonrpc.noReturn') - def no_return(_string: t.Optional[str] = None) -> t.NoReturn: - raise ValueError('no return') - - @jsonrpc.method('jsonrpc.invalidUnion1') - def invalid_union_1(color: t.Union[Color, NewColor]) -> t.Union[Color, NewColor]: - return color - - @jsonrpc.method('jsonrpc.invalidUnion2') - def invalid_union_2(color: t.Union[Color, NewColor, None] = None) -> t.Union[Color, NewColor, None]: - return color - - @jsonrpc.method('jsonrpc.literalType') - def literal_type(x: t.Literal['X']) -> t.Literal['X']: - return x - - @jsonrpc.method('jsonrpc.createColor') - def create_color(color: NewColor) -> Color: - return Color(id=1, name=color.name, tag=color.tag) - - @jsonrpc.method('jsonrpc.createManyColor') - def create_many_colors(colors: t.List[NewColor], color: t.Optional[NewColor] = None) -> t.List[Color]: - new_color = [Color(id=i, name=pet.name, tag=pet.tag) for i, pet in enumerate(colors)] - if color is not None: - return new_color + [Color(id=len(colors), name=color.name, tag=color.tag)] - return new_color - - @jsonrpc.method('jsonrpc.createManyFixColor') - def create_many_fix_colors(colors: t.Dict[str, NewPet]) -> t.List[Color]: - return [Color(id=int(color_id), name=color.name, tag=color.tag) for color_id, color in colors.items()] - - @jsonrpc.method('jsonrpc.removeColor') - def remove_color(color: t.Optional[Color] = None) -> t.Optional[Color]: - if color is not None and color.id > 10: - raise ColorNotFoundException( - 'Color not found', - ColorError(color_id=color.id, reason='The color with an ID greater than 10 does not exist.'), - ) - return color - - @jsonrpc.method('jsonrpc.createPet') - def create_pet(pet: NewPet) -> Pet: - return Pet(id=1, name=pet.name, tag=pet.tag) - - @jsonrpc.method('jsonrpc.createManyPet') - def create_many_pets(pets: t.List[NewPet], pet: t.Optional[NewPet] = None) -> t.List[Pet]: - new_pets = [Pet(id=i, name=pet.name, tag=pet.tag) for i, pet in enumerate(pets)] - if pet is not None: - return new_pets + [Pet(id=len(pets), name=pet.name, tag=pet.tag)] - return new_pets - - @jsonrpc.method('jsonrpc.createManyFixPet') - def create_many_fix_pets(pets: t.Dict[str, NewPet]) -> t.List[Pet]: - return [Pet(id=int(pet_id), name=pet.name, tag=pet.tag) for pet_id, pet in pets.items()] - - @jsonrpc.method('jsonrpc.removePet') - def remove_pet(pet: t.Optional[Pet] = None) -> t.Optional[Pet]: - if pet is not None and pet.id > 10: - raise PetNotFoundException( - 'Pet not found', PetError(pet_id=pet.id, reason='The pet with an ID greater than 10 does not exist.') - ) - return pet - - @jsonrpc.method('jsonrpc.createCar') - def create_car(car: NewCar) -> Car: - return Car(id=1, name=car.name, tag=car.tag) - - @jsonrpc.method('jsonrpc.createManyCar') - def create_many_cars(cars: t.List[NewCar], car: t.Optional[NewCar] = None) -> t.List[Car]: - new_cars = [Car(id=i, name=car.name, tag=car.tag) for i, car in enumerate(cars)] - if car is not None: - return new_cars + [Car(id=len(cars), name=car.name, tag=car.tag)] - return new_cars - - @jsonrpc.method('jsonrpc.createManyFixCar') - def create_many_fix_cars(cars: t.Dict[str, NewCar]) -> t.List[Car]: - return [Car(id=int(car_id), name=car.name, tag=car.tag) for car_id, car in cars.items()] - - @jsonrpc.method('jsonrpc.removeCar') - def remove_car(car: t.Optional[Car] = None) -> t.Optional[Car]: - if car is not None and car.id > 10: - raise CarNotFoundException( - 'Car not found', CarError(car_id=car.id, reason='The car with an ID greater than 10 does not exist.') - ) - return car - - class_app = App() - jsonrpc.register(class_app.index, name='classapp.index') - jsonrpc.register(class_app.greeting) - jsonrpc.register(class_app.hello) - jsonrpc.register(class_app.echo) - jsonrpc.register(class_app.notify) - jsonrpc.register(class_app.not_allow_notify, notification=False) - jsonrpc.register(class_app.fails) - - return flask_app - - -if __name__ == '__main__': - app = create_app({'SERVER_NAME': os.getenv('FLASK_SERVER_NAME')}) - app.run(host='0.0.0.0') diff --git a/tests/test_apps/app/__main__.py b/tests/test_apps/app/__main__.py new file mode 100644 index 00000000..27736008 --- /dev/null +++ b/tests/test_apps/app/__main__.py @@ -0,0 +1,32 @@ +# Copyright (c) 2024-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the Cenobit Technologies nor the names of +# its contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +import os + +from .app import create_app + +app = create_app({'SERVER_NAME': os.getenv('FLASK_SERVER_NAME')}) +app.run(host='0.0.0.0') diff --git a/tests/test_apps/app/app.py b/tests/test_apps/app/app.py new file mode 100644 index 00000000..396f5157 --- /dev/null +++ b/tests/test_apps/app/app.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python +# Copyright (c) 2012-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the Cenobit Technologies nor the names of +# its contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +import typing as t + +from flask import Flask, jsonify + +from flask_jsonrpc import JSONRPC + +from .features.class_apps import jsonrpc as jsonrpc_class_apps_app +from .features.decorators import jsonrpc as jsonrpc_decorators_app +from .features.jsonrpc_basic import jsonrpc as jsonrpc_jsonrpc_basic_app +from .features.error_handlers import jsonrpc as jsonrpc_error_handlers_app +from .features.types.python_stds import jsonrpc as jsonrpc_types_python_std_app +from .features.objects.python_classes import jsonrpc as jsonrpc_objects_python_classes_app +from .features.objects.pydantic_models import jsonrpc as jsonrpc_objects_pydantic_models_app +from .features.objects.python_dataclasses import jsonrpc as jsonrpc_objects_python_dataclasses_app + +if t.TYPE_CHECKING: + from flask import Response + + +def create_app(test_config: t.Optional[dict[str, t.Any]] = None) -> Flask: # noqa: C901 + """Create and configure an instance of the Flask application.""" + flask_app = Flask('apptest', instance_relative_config=True) + if test_config: + flask_app.config.update(test_config) + + jsonrpc = JSONRPC(flask_app, '/api', enable_web_browsable_api=True) + jsonrpc.register_blueprint( + flask_app, jsonrpc_jsonrpc_basic_app, url_prefix='/jsonrpc-basic', enable_web_browsable_api=True + ) + jsonrpc.register_blueprint( + flask_app, jsonrpc_class_apps_app, url_prefix='/class-apps', enable_web_browsable_api=True + ) + jsonrpc.register_blueprint( + flask_app, jsonrpc_decorators_app, url_prefix='/decorators', enable_web_browsable_api=True + ) + jsonrpc.register_blueprint( + flask_app, jsonrpc_error_handlers_app, url_prefix='/error-handlers', enable_web_browsable_api=True + ) + jsonrpc.register_blueprint( + flask_app, jsonrpc_types_python_std_app, url_prefix='/types/python-stds', enable_web_browsable_api=True + ) + jsonrpc.register_blueprint( + flask_app, + jsonrpc_objects_python_classes_app, + url_prefix='/objects/python-classes', + enable_web_browsable_api=True, + ) + jsonrpc.register_blueprint( + flask_app, + jsonrpc_objects_python_dataclasses_app, + url_prefix='/objects/python-dataclasses', + enable_web_browsable_api=True, + ) + jsonrpc.register_blueprint( + flask_app, + jsonrpc_objects_pydantic_models_app, + url_prefix='/objects/pydantic-models', + enable_web_browsable_api=True, + ) + + jsonrpc.errorhandler(ValueError) + + def handle_value_error_exception(ex: ValueError) -> dict[str, t.Any]: + return {'message': 'Generic global error handler does not work, :(', 'code': '0000'} + + @jsonrpc.method('jsonrpc.greeting') + def greeting(name: str = 'Flask JSON-RPC') -> str: + return f'Hello {name}' + + @flask_app.route('/health') + def health() -> 'Response': + return jsonify({'status': 'UP'}) + + return flask_app diff --git a/tests/test_apps/app/features/__init__.py b/tests/test_apps/app/features/__init__.py new file mode 100644 index 00000000..6023f811 --- /dev/null +++ b/tests/test_apps/app/features/__init__.py @@ -0,0 +1,26 @@ +# Copyright (c) 2024-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the Cenobit Technologies nor the names of +# its contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. diff --git a/tests/unit/conftest.py b/tests/test_apps/app/features/class_apps.py similarity index 54% rename from tests/unit/conftest.py rename to tests/test_apps/app/features/class_apps.py index ab448c64..786bcea9 100644 --- a/tests/unit/conftest.py +++ b/tests/test_apps/app/features/class_apps.py @@ -26,55 +26,48 @@ # POSSIBILITY OF SUCH DAMAGE. import typing as t -import pytest +from flask_jsonrpc import JSONRPCBlueprint -from ..test_apps.app import create_app -from ..test_apps.async_app import create_async_app -# Python 3.11+ -try: - from typing import Self -except ImportError: # pragma: no cover - from typing_extensions import Self # noqa: F401 +class App: + @staticmethod + def index(name: str = 'Flask JSON-RPC') -> str: + return f'Hello {name}' -if t.TYPE_CHECKING: - from flask import Flask - from flask.testing import FlaskClient, FlaskCliRunner + @staticmethod + def greeting(name: str = 'Flask JSON-RPC') -> str: + return f'Hello {name}' + @staticmethod + def hello(name: str = 'Flask JSON-RPC') -> str: + return f'Hello {name}' -@pytest.fixture(scope='module') -def app() -> 't.Generator[Flask]': - """Create and configure a new app instance for each test.""" - flask_app = create_app({'TESTING': True}) + @staticmethod + def echo(string: str, _some: t.Any = None) -> str: # noqa: ANN401 + return string - yield flask_app + @staticmethod + def notify(_string: t.Optional[str] = None) -> None: + pass + @staticmethod + def not_allow_notify(_string: t.Optional[str] = None) -> str: + return 'Now allow notify' -@pytest.fixture(scope='module') -def async_app() -> 't.Generator[Flask]': - """Create and configure a new async app instance for each test.""" - flask_app = create_async_app({'TESTING': True}) + @staticmethod + def fails(n: int) -> int: + if n % 2 == 0: + return n + raise ValueError('number is odd') - yield flask_app +class_app = App() -# pylint: disable=W0621 -@pytest.fixture(scope='module') -def client(app: 'Flask') -> 'FlaskClient': - """A test client for the app.""" - return app.test_client() - - -# pylint: disable=W0621 -@pytest.fixture(scope='module') -def async_client(async_app: 'Flask') -> 't.Generator[FlaskClient]': - """A test async client for the app.""" - with async_app.test_client() as testing_client: - yield testing_client - - -# pylint: disable=W0621 -@pytest.fixture -def runner(app: 'Flask') -> 'FlaskCliRunner': - """A test runner for the app's Click commands.""" - return app.test_cli_runner() +jsonrpc = JSONRPCBlueprint('class_apps', __name__) +jsonrpc.register(class_app.index, name='classapp.index') +jsonrpc.register(class_app.greeting) +jsonrpc.register(class_app.hello) +jsonrpc.register(class_app.echo) +jsonrpc.register(class_app.notify) +jsonrpc.register(class_app.not_allow_notify, notification=False) +jsonrpc.register(class_app.fails) diff --git a/tests/test_apps/app/features/decorators.py b/tests/test_apps/app/features/decorators.py new file mode 100644 index 00000000..ec848f2b --- /dev/null +++ b/tests/test_apps/app/features/decorators.py @@ -0,0 +1,62 @@ +# Copyright (c) 2024-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the Cenobit Technologies nor the names of +# its contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +import typing as t +import functools + +from flask_jsonrpc import JSONRPCBlueprint + + +def jsonrpc_decorator(fn: t.Callable[..., str]) -> t.Callable[..., str]: + def decorator(string: str) -> str: + rv = fn(string) + return f'{rv} from decorator, ;)' + + return decorator + + +def jsonrpc_decorator_wrapped(fn: t.Callable[..., str]) -> t.Callable[..., str]: + @functools.wraps(fn) + def decorator(string: str) -> str: + rv = fn(string) + return f'{rv} from decorator, ;)' + + return decorator + + +jsonrpc = JSONRPCBlueprint('decorators', __name__) + + +@jsonrpc.method('jsonrpc.decorator') +@jsonrpc_decorator +def decorator(string: str) -> str: + return f'Hello {string}' + + +@jsonrpc.method('jsonrpc.wrappedDecorator') +@jsonrpc_decorator_wrapped +def wrappedDecorator(string: str) -> str: + return f'Hello {string}' diff --git a/tests/test_apps/app/features/error_handlers.py b/tests/test_apps/app/features/error_handlers.py new file mode 100644 index 00000000..ffa624c0 --- /dev/null +++ b/tests/test_apps/app/features/error_handlers.py @@ -0,0 +1,55 @@ +# Copyright (c) 2024-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the Cenobit Technologies nor the names of +# its contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +import typing as t + +from flask_jsonrpc import JSONRPCBlueprint + + +class MyException(Exception): + pass + + +class MyNotRegisteredException(Exception): + pass + + +jsonrpc = JSONRPCBlueprint('error_handlers', __name__) + + +@jsonrpc.errorhandler(MyException) +def handle_my_exception(ex: MyException) -> dict[str, t.Any]: + return {'message': 'It is a custom exception', 'code': '0001'} + + +@jsonrpc.method('jsonrpc.failsWithCustomException') +def fails_with_custom_exception(_string: t.Optional[str] = None) -> t.NoReturn: + raise MyException('example of fail with custom exception that will be handled') + + +@jsonrpc.method('jsonrpc.failsWithCustomExceptionNotRegistered') +def fails_with_custom_exception_not_registered(_string: t.Optional[str] = None) -> t.NoReturn: + raise MyNotRegisteredException('example of fail with custom exception that will not be handled') diff --git a/tests/test_apps/app/features/jsonrpc_basic.py b/tests/test_apps/app/features/jsonrpc_basic.py new file mode 100644 index 00000000..6d3d2cbe --- /dev/null +++ b/tests/test_apps/app/features/jsonrpc_basic.py @@ -0,0 +1,100 @@ +# Copyright (c) 2012-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the Cenobit Technologies nor the names of +# its contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +import typing as t + +from flask_jsonrpc import JSONRPCBlueprint + +jsonrpc = JSONRPCBlueprint('jsonrpc_basic', __name__) + + +@jsonrpc.method('jsonrpc.greeting') +def greeting(name: str = 'Flask JSON-RPC') -> str: + return f'Hello {name}' + + +@jsonrpc.method('jsonrpc.echo') +def echo(string: str, _some: t.Any = None) -> str: # noqa: ANN401 + return string + + +@jsonrpc.method('jsonrpc.notify') +def notify(_string: t.Optional[str] = None) -> None: + pass + + +@jsonrpc.method('jsonrpc.not_allow_notify', notification=False) +def not_allow_notify(_string: str = 'None') -> str: + return 'Not allow notify' + + +@jsonrpc.method('jsonrpc.fails') +def fails(n: int) -> int: + if n % 2 == 0: + return n + raise ValueError('number is odd') + + +@jsonrpc.method('jsonrpc.strangeEcho') +def strange_echo( + string: str, omg: dict[str, t.Any], wtf: list[str], nowai: int, yeswai: str = 'Default' +) -> list[t.Any]: + return [string, omg, wtf, nowai, yeswai] + + +@jsonrpc.method('jsonrpc.sum') +def sum_(a: float, b: float) -> float: + return a + b + + +@jsonrpc.method('jsonrpc.returnStatusCode') +def return_status_code(s: str) -> tuple[str, int]: + return f'Status Code {s}', 201 + + +@jsonrpc.method('jsonrpc.returnHeaders') +def return_headers(s: str) -> tuple[str, dict[str, t.Any]]: + return f'Headers {s}', {'X-JSONRPC': '1'} + + +@jsonrpc.method('jsonrpc.returnStatusCodeAndHeaders') +def return_status_code_and_headers(s: str) -> tuple[str, int, dict[str, t.Any]]: + return f'Status Code and Headers {s}', 400, {'X-JSONRPC': '1'} + + +@jsonrpc.method('jsonrpc.not_validate', validate=False) +def not_validate(s='Oops!'): # noqa: ANN001,ANN202,ANN201 + return f'Not validate: {s}' + + +@jsonrpc.method('jsonrpc.mixin_not_validate', validate=False) +def mixin_not_validate(s, t: int, u, v: str, x, z): # noqa: ANN001,ANN202,ANN201 + return f'Not validate: {s} {t} {u} {v} {x} {z}' + + +@jsonrpc.method('jsonrpc.noReturn') +def no_return(_string: t.Optional[str] = None) -> t.NoReturn: + raise ValueError('no return') diff --git a/tests/test_apps/app/features/objects/__init__.py b/tests/test_apps/app/features/objects/__init__.py new file mode 100644 index 00000000..6023f811 --- /dev/null +++ b/tests/test_apps/app/features/objects/__init__.py @@ -0,0 +1,26 @@ +# Copyright (c) 2024-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the Cenobit Technologies nor the names of +# its contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. diff --git a/tests/test_apps/app/features/objects/pydantic_models.py b/tests/test_apps/app/features/objects/pydantic_models.py new file mode 100644 index 00000000..e50c4b38 --- /dev/null +++ b/tests/test_apps/app/features/objects/pydantic_models.py @@ -0,0 +1,100 @@ +# Copyright (c) 2024-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the Cenobit Technologies nor the names of +# its contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +import typing as t + +from pydantic import BaseModel + +from flask_jsonrpc import JSONRPCBlueprint + +# Python 3.11+ +try: + from typing import Self +except ImportError: # pragma: no cover + from typing_extensions import Self + + +class NewPet(BaseModel): + name: str + tag: str + + +class Pet(NewPet): + id: int + + +class PetError(BaseModel): + pet_id: int + reason: str + + +class PetException(Exception): + def __init__(self: Self, *args: object) -> None: + super().__init__(*args) + + +class PetNotFoundException(PetException): + def __init__(self: Self, message: str, pet_error: PetError) -> None: + super().__init__(message) + self.message = message + self.pet_error = pet_error + + +jsonrpc = JSONRPCBlueprint('objects__pydantic_models', __name__) + + +def handle_pet_not_found_exc(exc: PetNotFoundException) -> PetError: + return exc.pet_error + + +jsonrpc.register_error_handler(PetNotFoundException, handle_pet_not_found_exc) + + +@jsonrpc.method('jsonrpc.createPet') +def create_pet(pet: NewPet) -> Pet: + return Pet(id=1, name=pet.name, tag=pet.tag) + + +@jsonrpc.method('jsonrpc.createManyPet') +def create_many_pets(pets: list[NewPet], pet: t.Optional[NewPet] = None) -> list[Pet]: + new_pets = [Pet(id=i, name=pet.name, tag=pet.tag) for i, pet in enumerate(pets)] + if pet is not None: + return new_pets + [Pet(id=len(pets), name=pet.name, tag=pet.tag)] + return new_pets + + +@jsonrpc.method('jsonrpc.createManyFixPet') +def create_many_fix_pets(pets: dict[str, NewPet]) -> list[Pet]: + return [Pet(id=int(pet_id), name=pet.name, tag=pet.tag) for pet_id, pet in pets.items()] + + +@jsonrpc.method('jsonrpc.removePet') +def remove_pet(pet: t.Optional[Pet] = None) -> t.Optional[Pet]: + if pet is not None and pet.id > 10: + raise PetNotFoundException( + 'Pet not found', PetError(pet_id=pet.id, reason='The pet with an ID greater than 10 does not exist.') + ) + return pet diff --git a/tests/test_apps/app/features/objects/python_classes.py b/tests/test_apps/app/features/objects/python_classes.py new file mode 100644 index 00000000..5b55c458 --- /dev/null +++ b/tests/test_apps/app/features/objects/python_classes.py @@ -0,0 +1,106 @@ +# Copyright (c) 2024-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the Cenobit Technologies nor the names of +# its contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +import typing as t + +from flask_jsonrpc import JSONRPCBlueprint + +# Python 3.11+ +try: + from typing import Self +except ImportError: # pragma: no cover + from typing_extensions import Self + + +class NewColor: + name: str + tag: str + + def __init__(self: Self, name: str, tag: str) -> None: + self.name = name + self.tag = tag + + +class Color(NewColor): + id: int + + def __init__(self: Self, id: int, name: str, tag: str) -> None: + super().__init__(name, tag) + self.id = id + + +class ColorError: + def __init__(self: Self, color_id: int, reason: str) -> None: + self.color_id = color_id + self.reason = reason + + +class ColorException(Exception): + def __init__(self: Self, *args: object) -> None: + super().__init__(*args) + + +class ColorNotFoundException(ColorException): + def __init__(self: Self, message: str, color_error: ColorError) -> None: + super().__init__(message) + self.message = message + self.color_error = color_error + + +jsonrpc = JSONRPCBlueprint('objects__python_classes', __name__) + + +@jsonrpc.errorhandler(ColorNotFoundException) +def handle_color_not_found_exc(exc: ColorNotFoundException) -> ColorError: + return exc.color_error + + +@jsonrpc.method('jsonrpc.createColor') +def create_color(color: NewColor) -> Color: + return Color(id=1, name=color.name, tag=color.tag) + + +@jsonrpc.method('jsonrpc.createManyColor') +def create_many_colors(colors: list[NewColor], color: t.Optional[NewColor] = None) -> list[Color]: + new_color = [Color(id=i, name=pet.name, tag=pet.tag) for i, pet in enumerate(colors)] + if color is not None: + return new_color + [Color(id=len(colors), name=color.name, tag=color.tag)] + return new_color + + +@jsonrpc.method('jsonrpc.createManyFixColor') +def create_many_fix_colors(colors: dict[str, NewColor]) -> list[Color]: + return [Color(id=int(color_id), name=color.name, tag=color.tag) for color_id, color in colors.items()] + + +@jsonrpc.method('jsonrpc.removeColor') +def remove_color(color: t.Optional[Color] = None) -> t.Optional[Color]: + if color is not None and color.id > 10: + raise ColorNotFoundException( + 'Color not found', + ColorError(color_id=color.id, reason='The color with an ID greater than 10 does not exist.'), + ) + return color diff --git a/tests/test_apps/app/features/objects/python_dataclasses.py b/tests/test_apps/app/features/objects/python_dataclasses.py new file mode 100644 index 00000000..b91cc4d8 --- /dev/null +++ b/tests/test_apps/app/features/objects/python_dataclasses.py @@ -0,0 +1,100 @@ +# Copyright (c) 2012-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the Cenobit Technologies nor the names of +# its contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +import typing as t +from dataclasses import dataclass + +from flask_jsonrpc import JSONRPCBlueprint + +# Python 3.11+ +try: + from typing import Self +except ImportError: # pragma: no cover + from typing_extensions import Self + + +@dataclass +class NewCar: + name: str + tag: str + + +@dataclass +class Car(NewCar): + id: int + + +@dataclass +class CarError: + car_id: int + reason: str + + +class CarException(Exception): + def __init__(self: Self, *args: object) -> None: + super().__init__(*args) + + +class CarNotFoundException(CarException): + def __init__(self: Self, message: str, car_error: CarError) -> None: + super().__init__(message) + self.message = message + self.car_error = car_error + + +jsonrpc = JSONRPCBlueprint('objects__python_dataclasses', __name__) + + +@jsonrpc.errorhandler(CarNotFoundException) +def handle_car_not_found_exc(exc: CarNotFoundException) -> CarError: + return exc.car_error + + +@jsonrpc.method('jsonrpc.createCar') +def create_car(car: NewCar) -> Car: + return Car(id=1, name=car.name, tag=car.tag) + + +@jsonrpc.method('jsonrpc.createManyCar') +def create_many_cars(cars: list[NewCar], car: t.Optional[NewCar] = None) -> list[Car]: + new_cars = [Car(id=i, name=car.name, tag=car.tag) for i, car in enumerate(cars)] + if car is not None: + return new_cars + [Car(id=len(cars), name=car.name, tag=car.tag)] + return new_cars + + +@jsonrpc.method('jsonrpc.createManyFixCar') +def create_many_fix_cars(cars: dict[str, NewCar]) -> list[Car]: + return [Car(id=int(car_id), name=car.name, tag=car.tag) for car_id, car in cars.items()] + + +@jsonrpc.method('jsonrpc.removeCar') +def remove_car(car: t.Optional[Car] = None) -> t.Optional[Car]: + if car is not None and car.id > 10: + raise CarNotFoundException( + 'Car not found', CarError(car_id=car.id, reason='The car with an ID greater than 10 does not exist.') + ) + return car diff --git a/tests/test_apps/app/features/types/__init__.py b/tests/test_apps/app/features/types/__init__.py new file mode 100644 index 00000000..6023f811 --- /dev/null +++ b/tests/test_apps/app/features/types/__init__.py @@ -0,0 +1,26 @@ +# Copyright (c) 2024-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the Cenobit Technologies nor the names of +# its contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. diff --git a/tests/test_apps/app/features/types/python_stds.py b/tests/test_apps/app/features/types/python_stds.py new file mode 100644 index 00000000..2c8578d4 --- /dev/null +++ b/tests/test_apps/app/features/types/python_stds.py @@ -0,0 +1,182 @@ +# Copyright (c) 2024-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the Cenobit Technologies nor the names of +# its contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +from enum import IntEnum +import typing as t +from decimal import Decimal +from collections import deque + +from typing_extensions import Literal, TypedDict + +from flask_jsonrpc import JSONRPCBlueprint + +jsonrpc = JSONRPCBlueprint('types__python_types', __name__) + + +class ColorIntEnum(IntEnum): + RED = 1 + GREEN = 2 + BLUE = 3 + + +class UserTypedDict(TypedDict): + name: str + id: int + + +class EmployeeNamedTuple(t.NamedTuple): + name: str + id: int = 3 + + +@jsonrpc.method('jsonrpc.boolType') +def bool_type(yes: bool) -> bool: + return yes + + +@jsonrpc.method('jsonrpc.strType') +def str_type(st: str) -> str: + return st + + +@jsonrpc.method('jsonrpc.bytesType') +def bytes_type(b: bytes) -> bytes: + return b + + +@jsonrpc.method('jsonrpc.bytearrayType') +def bytearray_type(b: bytearray) -> bytearray: + return b + + +@jsonrpc.method('jsonrpc.intType') +def int_type(n: int) -> int: + return n + + +@jsonrpc.method('jsonrpc.floatType') +def float_type(n: float) -> float: + return n + + +@jsonrpc.method('jsonrpc.intEnumType') +def enum_int_type(e: ColorIntEnum) -> ColorIntEnum: + return e + + +@jsonrpc.method('jsonrpc.decimalType') +def decimal_type(n: Decimal) -> Decimal: + return n + + +@jsonrpc.method('jsonrpc.listType') +def list_type(lst: list[int]) -> list[int]: + return lst + + +@jsonrpc.method('jsonrpc.tupleType') +def tuple_type(tn: tuple[int, int]) -> tuple[tuple[int, int], int]: + return tn, 200 + + +@jsonrpc.method('jsonrpc.namedtupleType') +def namedtuple_type(tn: EmployeeNamedTuple) -> tuple[EmployeeNamedTuple, int]: + return tn, 200 + + +@jsonrpc.method('jsonrpc.setType') +def set_type(s: set[int]) -> set[int]: + return s + + +@jsonrpc.method('jsonrpc.frozensetType') +def fronzenset_type(s: frozenset[int]) -> frozenset[int]: + return s + + +@jsonrpc.method('jsonrpc.dequeType') +def deque_type(d: deque[int]) -> deque[int]: + return d + + +@jsonrpc.method('jsonrpc.sequenceType') +def sequence_type(s: t.Sequence[int]) -> t.Sequence[int]: + return s + + +@jsonrpc.method('jsonrpc.dictType') +def dict_type(d: dict[str, int]) -> dict[str, int]: + return d + + +@jsonrpc.method('jsonrpc.typedDictType') +def typeddict_type(user: UserTypedDict) -> UserTypedDict: + return user + + +@jsonrpc.method('jsonrpc.optional') +def optional(n: t.Optional[int] = None) -> t.Optional[int]: + return n + + +@jsonrpc.method('jsonrpc.unionWithTwoTypes') +def union_with_two_types(n: t.Union[int, float]) -> t.Union[int, float]: + return n + + +@jsonrpc.method('jsonrpc.unionWithTwoTypesAndNone') +def union_with_two_types_and_none(n: t.Union[int, float, None] = None) -> t.Union[int, float, None]: + return n + + +@jsonrpc.method('jsonrpc.literalType') +def literal_type(x: Literal['X']) -> Literal['X']: + return x + + +@jsonrpc.method('jsonrpc.finalType') +def final_type(x: t.Final[str] = 'FinalValue') -> t.Final[str]: # type: ignore + return x + + +@jsonrpc.method('jsonrpc.anyType') +def any_type(obj: t.Any) -> t.Any: # noqa: ANN401 + return obj + + +@jsonrpc.method('jsonrpc.noneType') +def none_type(obj: None = None) -> None: # noqa: ANN401 + return obj + + +@jsonrpc.method('jsonrpc.noReturnType') +def no_return_type(s: str) -> t.NoReturn: # noqa: ANN401 + raise ValueError(s) + + +@jsonrpc.method('jsonrpc.literalNoneType') +def literal_none_type(x: Literal[None] = None) -> Literal[None]: # noqa: ANN401 + return x diff --git a/tests/test_apps/async_app/__init__.py b/tests/test_apps/async_app/__init__.py index 7b12b07d..6a250cab 100644 --- a/tests/test_apps/async_app/__init__.py +++ b/tests/test_apps/async_app/__init__.py @@ -1,5 +1,4 @@ -#!/usr/bin/env python -# Copyright (c) 2012-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# Copyright (c) 2022-2024, Cenobit Technologies, Inc. http://cenobit.es/ # All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -25,398 +24,3 @@ # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -import os -import typing as t -import asyncio -import functools -from dataclasses import dataclass - -from flask import Flask - -# Python 3.11+ -try: - from typing import Self -except ImportError: # pragma: no cover - from typing_extensions import Self - -from pydantic import BaseModel - -from flask_jsonrpc import JSONRPC - - -class NewColor: - name: str - tag: str - - def __init__(self: Self, name: str, tag: str) -> None: - self.name = name - self.tag = tag - - -class Color(NewColor): - id: int - - def __init__(self: Self, id: int, name: str, tag: str) -> None: - super().__init__(name, tag) - self.id = id - - -class ColorError: - def __init__(self: Self, color_id: int, reason: str) -> None: - self.color_id = color_id - self.reason = reason - - -class ColorException(Exception): - def __init__(self: Self, *args: object) -> None: - super().__init__(*args) - - -class ColorNotFoundException(ColorException): - def __init__(self: Self, message: str, color_error: ColorError) -> None: - super().__init__(message) - self.message = message - self.color_error = color_error - - -@dataclass -class NewCar: - name: str - tag: str - - -@dataclass -class Car(NewCar): - id: int - - -@dataclass -class CarError: - car_id: int - reason: str - - -class CarException(Exception): - def __init__(self: Self, *args: object) -> None: - super().__init__(*args) - - -class CarNotFoundException(CarException): - def __init__(self: Self, message: str, car_error: CarError) -> None: - super().__init__(message) - self.message = message - self.car_error = car_error - - -class NewPet(BaseModel): - name: str - tag: str - - -class Pet(NewPet): - id: int - - -class PetError(BaseModel): - pet_id: int - reason: str - - -class PetException(Exception): - def __init__(self: Self, *args: object) -> None: - super().__init__(*args) - - -class PetNotFoundException(PetException): - def __init__(self: Self, message: str, pet_error: PetError) -> None: - super().__init__(message) - self.message = message - self.pet_error = pet_error - - -class App: - @staticmethod - async def index(name: str = 'Flask JSON-RPC') -> str: - await asyncio.sleep(0) - return f'Hello {name}' - - @staticmethod - async def greeting(name: str = 'Flask JSON-RPC') -> str: - await asyncio.sleep(0) - return f'Hello {name}' - - @staticmethod - async def hello(name: str = 'Flask JSON-RPC') -> str: - await asyncio.sleep(0) - return f'Hello {name}' - - @staticmethod - async def echo(string: str, _some: t.Any = None) -> str: # noqa: ANN401 - await asyncio.sleep(0) - return string - - @staticmethod - async def notify(_string: t.Optional[str] = None) -> None: - await asyncio.sleep(0) - - @staticmethod - async def not_allow_notify(_string: t.Optional[str] = None) -> str: - await asyncio.sleep(0) - return 'Now allow notify' - - @staticmethod - async def fails(n: int) -> int: - await asyncio.sleep(0) - if n % 2 == 0: - return n - raise ValueError('number is odd') - - -def async_jsonrpc_decorator(fn: t.Callable[..., t.Awaitable[str]]) -> t.Callable[..., t.Awaitable[str]]: - async def decorator(string: str) -> str: - await asyncio.sleep(0) - rv = await fn(string) - return f'{rv} from decorator, ;)' - - return decorator - - -def async_jsonrpc_decorator_wrapped(fn: t.Callable[..., t.Awaitable[str]]) -> t.Callable[..., t.Awaitable[str]]: - @functools.wraps(fn) - async def decorator(string: str) -> str: - await asyncio.sleep(0) - rv = await fn(string) - return f'{rv} from decorator, ;)' - - return decorator - - -def create_async_app(test_config: t.Optional[t.Dict[str, t.Any]] = None) -> Flask: # noqa: C901 pylint: disable=W0612 - """Create and configure an instance of the Flask application.""" - flask_app = Flask('apptest', instance_relative_config=True) - if test_config: - flask_app.config.update(test_config) - - jsonrpc = JSONRPC(flask_app, '/api', enable_web_browsable_api=True) - - @jsonrpc.errorhandler(ColorNotFoundException) - async def handle_color_not_found_exc(exc: ColorNotFoundException) -> ColorError: - await asyncio.sleep(0) - return exc.color_error - - async def handle_pet_not_found_exc(exc: PetNotFoundException) -> PetError: - await asyncio.sleep(0) - return exc.pet_error - - jsonrpc.register_error_handler(PetNotFoundException, handle_pet_not_found_exc) - - @jsonrpc.errorhandler(CarNotFoundException) - async def handle_car_not_found_exc(exc: CarNotFoundException) -> CarError: - return exc.car_error - - # pylint: disable=W0612 - @jsonrpc.method('jsonrpc.greeting') - async def greeting(name: str = 'Flask JSON-RPC') -> str: - await asyncio.sleep(0) - return f'Hello {name}' - - # pylint: disable=W0612 - @jsonrpc.method('jsonrpc.echo') - async def echo(string: str, _some: t.Any = None) -> str: # noqa: ANN401 - await asyncio.sleep(0) - return string - - # pylint: disable=W0612 - @jsonrpc.method('jsonrpc.notify') - async def notify(_string: t.Optional[str] = None) -> None: - await asyncio.sleep(0) - - # pylint: disable=W0612 - @jsonrpc.method('jsonrpc.not_allow_notify', notification=False) - async def not_allow_notify(_string: t.Optional[str] = None) -> str: - await asyncio.sleep(0) - return 'Not allow notify' - - # pylint: disable=W0612 - @jsonrpc.method('jsonrpc.fails') - async def fails(n: int) -> int: - await asyncio.sleep(0) - if n % 2 == 0: - return n - raise ValueError('number is odd') - - # pylint: disable=W0612 - @jsonrpc.method('jsonrpc.strangeEcho') - async def strange_echo( - string: str, omg: t.Dict[str, t.Any], wtf: t.List[str], nowai: int, yeswai: str = 'Default' - ) -> t.List[t.Any]: - await asyncio.sleep(0) - return [string, omg, wtf, nowai, yeswai] - - # pylint: disable=W0612 - @jsonrpc.method('jsonrpc.sum') - async def sum_(a: t.Union[int, float], b: t.Union[int, float]) -> t.Union[int, float]: - await asyncio.sleep(0) - return a + b - - # pylint: disable=W0612 - @jsonrpc.method('jsonrpc.decorators') - @async_jsonrpc_decorator - async def decorators(string: str) -> str: - await asyncio.sleep(0) - return f'Hello {string}' - - # pylint: disable=W0612 - @jsonrpc.method('jsonrpc.decoratorsWrapped') - @async_jsonrpc_decorator_wrapped - async def decorators_wrapped(string: str) -> str: - await asyncio.sleep(0) - return f'Hello {string}' - - # pylint: disable=W0612 - @jsonrpc.method('jsonrpc.returnStatusCode') - async def return_status_code(s: str) -> t.Tuple[str, int]: - await asyncio.sleep(0) - return f'Status Code {s}', 201 - - # pylint: disable=W0612 - @jsonrpc.method('jsonrpc.returnHeaders') - async def return_headers(s: str) -> t.Tuple[str, t.Dict[str, t.Any]]: - await asyncio.sleep(0) - return f'Headers {s}', {'X-JSONRPC': '1'} - - # pylint: disable=W0612 - @jsonrpc.method('jsonrpc.returnStatusCodeAndHeaders') - async def return_status_code_and_headers(s: str) -> t.Tuple[str, int, t.Dict[str, t.Any]]: - await asyncio.sleep(0) - return f'Status Code and Headers {s}', 400, {'X-JSONRPC': '1'} - - # pylint: disable=W0612 - @jsonrpc.method('jsonrpc.not_validate', validate=False) - async def not_validate(s='Oops!'): # noqa: ANN001,ANN202 - await asyncio.sleep(0) - return f'Not validate: {s}' - - # pylint: disable=W0612 - @jsonrpc.method('jsonrpc.mixin_not_validate', validate=False) - async def mixin_not_validate(s, t: int, u, v: str, x, z): # noqa: ANN001,ANN202 - await asyncio.sleep(0) - return f'Not validate: {s} {t} {u} {v} {x} {z}' - - @jsonrpc.method('jsonrpc.noReturn') - async def no_return(_string: t.Optional[str] = None) -> t.NoReturn: - await asyncio.sleep(0) - raise ValueError('no return') - - @jsonrpc.method('jsonrpc.invalidUnion1') - async def invalid_union_1(color: t.Union[Color, NewColor]) -> t.Union[Color, NewColor]: - await asyncio.sleep(0) - return color - - @jsonrpc.method('jsonrpc.invalidUnion2') - async def invalid_union_2(color: t.Union[Color, NewColor, None] = None) -> t.Union[Color, NewColor, None]: - await asyncio.sleep(0) - return color - - @jsonrpc.method('jsonrpc.literalType') - async def literal_type(x: t.Literal['X']) -> t.Literal['X']: - await asyncio.sleep(0) - return x - - @jsonrpc.method('jsonrpc.createColor') - async def create_color(color: NewColor) -> Color: - await asyncio.sleep(0) - return Color(id=1, name=color.name, tag=color.tag) - - @jsonrpc.method('jsonrpc.createManyColor') - async def create_many_colors(colors: t.List[NewColor], color: t.Optional[NewColor] = None) -> t.List[Color]: - await asyncio.sleep(0) - new_color = [Color(id=i, name=pet.name, tag=pet.tag) for i, pet in enumerate(colors)] - if color is not None: - return new_color + [Color(id=len(colors), name=color.name, tag=color.tag)] - return new_color - - @jsonrpc.method('jsonrpc.createManyFixColor') - async def create_many_fix_colors(colors: t.Dict[str, NewPet]) -> t.List[Color]: - await asyncio.sleep(0) - return [Color(id=int(color_id), name=color.name, tag=color.tag) for color_id, color in colors.items()] - - @jsonrpc.method('jsonrpc.removeColor') - async def remove_color(color: t.Optional[Color] = None) -> t.Optional[Color]: - await asyncio.sleep(0) - if color is not None and color.id > 10: - raise ColorNotFoundException( - 'Color not found', - ColorError(color_id=color.id, reason='The color with an ID greater than 10 does not exist.'), - ) - return color - - @jsonrpc.method('jsonrpc.createPet') - async def create_pet(pet: NewPet) -> Pet: - await asyncio.sleep(0) - return Pet(id=1, name=pet.name, tag=pet.tag) - - @jsonrpc.method('jsonrpc.createManyPet') - async def create_many_pets(pets: t.List[NewPet], pet: t.Optional[NewPet] = None) -> t.List[Pet]: - await asyncio.sleep(0) - new_pets = [Pet(id=i, name=pet.name, tag=pet.tag) for i, pet in enumerate(pets)] - if pet is not None: - return new_pets + [Pet(id=len(pets), name=pet.name, tag=pet.tag)] - return new_pets - - @jsonrpc.method('jsonrpc.createManyFixPet') - async def create_many_fix_pets(pets: t.Dict[str, NewPet]) -> t.List[Pet]: - await asyncio.sleep(0) - return [Pet(id=int(pet_id), name=pet.name, tag=pet.tag) for pet_id, pet in pets.items()] - - @jsonrpc.method('jsonrpc.removePet') - async def remove_pet(pet: t.Optional[Pet] = None) -> t.Optional[Pet]: - await asyncio.sleep(0) - if pet is not None and pet.id > 10: - raise PetNotFoundException( - 'Pet not found', PetError(pet_id=pet.id, reason='The pet with an ID greater than 10 does not exist.') - ) - return pet - - @jsonrpc.method('jsonrpc.createCar') - async def create_car(car: NewCar) -> Car: - await asyncio.sleep(0) - return Car(id=1, name=car.name, tag=car.tag) - - @jsonrpc.method('jsonrpc.createManyCar') - async def create_many_cars(cars: t.List[NewCar], car: t.Optional[NewCar] = None) -> t.List[Car]: - await asyncio.sleep(0) - new_cars = [Car(id=i, name=car.name, tag=car.tag) for i, car in enumerate(cars)] - if car is not None: - return new_cars + [Car(id=len(cars), name=car.name, tag=car.tag)] - return new_cars - - @jsonrpc.method('jsonrpc.createManyFixCar') - async def create_many_fix_cars(cars: t.Dict[str, NewCar]) -> t.List[Car]: - await asyncio.sleep(0) - return [Car(id=int(car_id), name=car.name, tag=car.tag) for car_id, car in cars.items()] - - @jsonrpc.method('jsonrpc.removeCar') - async def remove_car(car: t.Optional[Car] = None) -> t.Optional[Car]: - await asyncio.sleep(0) - if car is not None and car.id > 10: - raise CarNotFoundException( - 'Car not found', CarError(car_id=car.id, reason='The car with an ID greater than 10 does not exist.') - ) - return car - - class_app = App() - jsonrpc.register(class_app.index, name='classapp.index') - jsonrpc.register(class_app.greeting) - jsonrpc.register(class_app.hello) - jsonrpc.register(class_app.echo) - jsonrpc.register(class_app.notify) - jsonrpc.register(class_app.not_allow_notify, notification=False) - jsonrpc.register(class_app.fails) - - return flask_app - - -if __name__ == '__main__': - app = create_async_app({'SERVER_NAME': os.getenv('FLASK_SERVER_NAME')}) - app.run(host='0.0.0.0') diff --git a/tests/test_apps/async_app/__main__.py b/tests/test_apps/async_app/__main__.py new file mode 100644 index 00000000..27736008 --- /dev/null +++ b/tests/test_apps/async_app/__main__.py @@ -0,0 +1,32 @@ +# Copyright (c) 2024-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the Cenobit Technologies nor the names of +# its contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +import os + +from .app import create_app + +app = create_app({'SERVER_NAME': os.getenv('FLASK_SERVER_NAME')}) +app.run(host='0.0.0.0') diff --git a/tests/test_apps/async_app/app.py b/tests/test_apps/async_app/app.py new file mode 100644 index 00000000..76573646 --- /dev/null +++ b/tests/test_apps/async_app/app.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python +# Copyright (c) 2012-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the Cenobit Technologies nor the names of +# its contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +import os +import typing as t +import asyncio +import functools + +from flask import Flask, jsonify + +from flask_jsonrpc import JSONRPC + +if t.TYPE_CHECKING: + from flask import Response + + +def async_jsonrpc_decorator(fn: t.Callable[..., t.Awaitable[str]]) -> t.Callable[..., t.Awaitable[str]]: + async def decorator(string: str) -> str: + await asyncio.sleep(0) + rv = await fn(string) + return f'{rv} from decorator, ;)' + + return decorator + + +def async_jsonrpc_decorator_wrapped(fn: t.Callable[..., t.Awaitable[str]]) -> t.Callable[..., t.Awaitable[str]]: + @functools.wraps(fn) + async def decorator(string: str) -> str: + await asyncio.sleep(0) + rv = await fn(string) + return f'{rv} from decorator, ;)' + + return decorator + + +class MyException(Exception): + pass + + +class MyNotRegisteredException(Exception): + pass + + +def create_app(test_config: t.Optional[dict[str, t.Any]] = None) -> Flask: # noqa: C901 + """Create and configure an instance of the Flask application.""" + flask_app = Flask('apptest', instance_relative_config=True) + if test_config: + flask_app.config.update(test_config) + + jsonrpc = JSONRPC(flask_app, '/api', enable_web_browsable_api=True) + + @jsonrpc.errorhandler(MyException) + async def handle_my_exception(ex: MyException) -> dict[str, t.Any]: + await asyncio.sleep(0) + return {'message': 'It is a custom exception', 'code': '0001'} + + @jsonrpc.method('jsonrpc.greeting') + async def greeting(name: str = 'Flask JSON-RPC') -> str: + await asyncio.sleep(0) + return f'Hello {name}' + + @jsonrpc.method('jsonrpc.echo') + async def echo(string: str, _some: t.Any = None) -> str: # noqa: ANN401 + await asyncio.sleep(0) + return string + + @jsonrpc.method('jsonrpc.notify') + async def notify(_string: t.Optional[str] = None) -> None: + await asyncio.sleep(0) + + @jsonrpc.method('jsonrpc.fails') + async def fails(n: int) -> int: + await asyncio.sleep(0) + if n % 2 == 0: + return n + raise ValueError('number is odd') + + @jsonrpc.method('jsonrpc.decorators') + @async_jsonrpc_decorator + async def decorators(string: str) -> str: + await asyncio.sleep(0) + return f'Hello {string}' + + @jsonrpc.method('jsonrpc.wrappedDecorators') + @async_jsonrpc_decorator_wrapped + async def wrapped_decorators(string: str) -> str: + await asyncio.sleep(0) + return f'Hello {string}' + + @jsonrpc.method('jsonrpc.failsWithCustomException') + async def fails_with_custom_exception(_string: t.Optional[str] = None) -> t.NoReturn: + await asyncio.sleep(0) + raise MyException('example of fail with custom exception that will be handled') + + @jsonrpc.method('jsonrpc.failsWithCustomExceptionNotRegistered') + async def fails_with_custom_exception_not_registered(_string: t.Optional[str] = None) -> t.NoReturn: + await asyncio.sleep(0) + raise MyNotRegisteredException('example of fail with custom exception that will not be handled') + + @flask_app.route('/health') + async def health() -> 'Response': + return jsonify({'status': 'UP'}) + + return flask_app + + +if __name__ == '__main__': + app = create_app({'SERVER_NAME': os.getenv('FLASK_SERVER_NAME')}) + app.run(host='0.0.0.0') diff --git a/tests/unit/contrib/browse/test_app.py b/tests/unit/contrib/browse/test_app.py index 1a604856..b15646ab 100644 --- a/tests/unit/contrib/browse/test_app.py +++ b/tests/unit/contrib/browse/test_app.py @@ -35,23 +35,19 @@ def test_browse_create() -> None: app = Flask('test_browse', instance_relative_config=True) jsonrpc = JSONRPC(app, '/api', enable_web_browsable_api=True) - # pylint: disable=W0612 @jsonrpc.method('app.fn1', validate=False) def fn1(s): # noqa: ANN001,ANN202 """Function app.fn1""" return f'Foo {s}' - # pylint: disable=W0612 @jsonrpc.method('app.fn2', notification=True) def fn2(s: str) -> str: return f'Foo {s}' - # pylint: disable=W0612 @jsonrpc.method('app.fn3', notification=False) def fn3(s: str) -> str: return f'Foo {s}' - # pylint: disable=W0612 @jsonrpc.method('app.fn4', validate=False) def fn4(s, t: int, u, v: str, x, z): # noqa: ANN001,ANN202 return f'Not validate: {s} {t} {u} {v} {x} {z}' @@ -205,7 +201,6 @@ def test_browse_create_without_register_app() -> None: app = Flask('test_browse', instance_relative_config=True) jsonrpc = JSONRPC(service_url='/api', enable_web_browsable_api=True) - # pylint: disable=W0612 @jsonrpc.method('app.fn2') def fn1(s: str) -> str: return f'Foo {s}' @@ -242,22 +237,18 @@ def test_browse_create_multiple_jsonrpc_versions() -> None: jsonrpc_v1 = JSONRPC(app, '/api/v1', enable_web_browsable_api=True) jsonrpc_v2 = JSONRPC(app, '/api/v2', enable_web_browsable_api=True) - # pylint: disable=W0612 @jsonrpc_v1.method('app.fn2') def fn1_v1(s: str) -> str: return f'v1: Foo {s}' - # pylint: disable=W0612 @jsonrpc_v1.method('app.fn3') def fn3(s: str) -> str: return f'Poo {s}' - # pylint: disable=W0612 @jsonrpc_v2.method('app.fn2') def fn1_v2(s: str) -> str: return f'v2: Foo {s}' - # pylint: disable=W0612 @jsonrpc_v2.method('app.fn1') def fn2(s: str) -> str: return f'Bar {s}' @@ -356,31 +347,26 @@ def fn2(s: str) -> str: def test_browse_create_modular_apps() -> None: jsonrpc_api_1 = JSONRPCBlueprint('jsonrpc_api_1', __name__) - # pylint: disable=W0612 @jsonrpc_api_1.method('blue1.fn2') def fn1_b1(s: str) -> str: return f'b1: Foo {s}' jsonrpc_api_2 = JSONRPCBlueprint('jsonrpc_api_2', __name__) - # pylint: disable=W0612 @jsonrpc_api_2.method('blue2.fn2') def fn1_b2(s: str) -> str: return f'b2: Foo {s}' - # pylint: disable=W0612 @jsonrpc_api_2.method('blue2.fn1') def fn2_b2(s: str) -> str: return f'b2: Bar {s}' - # pylint: disable=W0612 @jsonrpc_api_2.method('blue2.not_notify', notification=False) def fn3_b2(s: str) -> str: return f'fn3 b2: Foo {s}' jsonrpc_api_3 = JSONRPCBlueprint('jsonrpc_api_3', __name__) - # pylint: disable=W0612 @jsonrpc_api_3.method('blue3.fn2') def fn1_b3(s: str) -> str: return f'fn1 b3: Foo {s}' diff --git a/tests/unit/contrib/openrpc/test_app.py b/tests/unit/contrib/openrpc/test_app.py index 76ac12f3..7264a4ee 100644 --- a/tests/unit/contrib/openrpc/test_app.py +++ b/tests/unit/contrib/openrpc/test_app.py @@ -50,13 +50,11 @@ def test_openrpc_create() -> None: ), ) - # pylint: disable=W0612 @jsonrpc.method('app.fn1', validate=False) def fn1(s): # noqa: ANN001,ANN202 """Function app.fn1""" return f'Foo {s}' - # pylint: disable=W0612 @openrpc.extend_schema( name='FN2', summary='Function app.fn2', @@ -88,7 +86,6 @@ def fn1(s): # noqa: ANN001,ANN202 def fn2(s: str) -> str: return f'Foo {s}' - # pylint: disable=W0612 @openrpc.extend_schema( name='FN3', summary='Function app.fn3', @@ -120,13 +117,11 @@ def fn2(s: str) -> str: def fn3(s: str) -> str: return f'Foo {s}' - # pylint: disable=W0612 @openrpc.extend_schema(name='FN4') @jsonrpc.method('app.fn4', notification=False) def fn4(s: str) -> str: return f'Foo {s}' - # pylint: disable=W0612 @jsonrpc.method('app.fn5', validate=False) def fn5(s, t: int, u, v: str, x, z): # noqa: ANN001,ANN202 return f'Not validate: {s} {t} {u} {v} {x} {z}' @@ -273,29 +268,24 @@ def test_openrpc_create_by_autogenerate() -> None: openrpc = OpenRPC() openrpc.init_app(app, jsonrpc) - # pylint: disable=W0612 @jsonrpc.method('app.fn1', validate=False) def fn1(s): # noqa: ANN001,ANN202 """Function app.fn1""" return f'Foo {s}' - # pylint: disable=W0612 @jsonrpc.method('app.fn2', notification=True) def fn2(s: str) -> str: return f'Foo {s}' - # pylint: disable=W0612 @jsonrpc.method('app.fn3', validate=False) def fn3(s: str) -> str: return f'Foo {s}' - # pylint: disable=W0612 @openrpc.extend_schema(name='FN3') @jsonrpc.method('app.fn4', notification=False) def fn4(s: str) -> str: return f'Foo {s}' - # pylint: disable=W0612 @jsonrpc.method('app.fn5', validate=False) def fn5(s, t: int, u, v: str, x, z): # noqa: ANN001,ANN202 return f'Not validate: {s} {t} {u} {v} {x} {z}' @@ -387,14 +377,12 @@ def fn5(s, t: int, u, v: str, x, z): # noqa: ANN001,ANN202 def test_openrpc_create_with_blueprint() -> None: article = JSONRPCBlueprint('article', __name__) - # pylint: disable=W0612 @article.method('Article.index') def article_index() -> str: return 'Welcome to Article API' user = JSONRPCBlueprint('user', __name__) - # pylint: disable=W0612 @user.method('User.index') def user_index() -> str: return 'Welcome to User API' @@ -405,7 +393,6 @@ def user_index() -> str: jsonrpc.register_blueprint(app, article, url_prefix='/article') openrpc = OpenRPC(app=app, jsonrpc_app=jsonrpc) - # pylint: disable=W0612 @openrpc.extend_schema(name='App.index') @jsonrpc.method('App.index') def index() -> str: @@ -499,7 +486,6 @@ def test_openrpc_disabled() -> None: app = Flask('test_openrpc', instance_relative_config=True) jsonrpc = JSONRPC(app, '/api') - # pylint: disable=W0612 @jsonrpc.method('app.fn1', validate=False) def fn1(s): # noqa: ANN001,ANN202 """Function app.fn1""" diff --git a/tests/unit/shared/__init__.py b/tests/unit/shared/__init__.py new file mode 100644 index 00000000..fba6a4d9 --- /dev/null +++ b/tests/unit/shared/__init__.py @@ -0,0 +1,26 @@ +# Copyright (c) 2020-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the Cenobit Technologies nor the names of +# its contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. diff --git a/tests/unit/shared/conftest.py b/tests/unit/shared/conftest.py new file mode 100644 index 00000000..cbb4cb6f --- /dev/null +++ b/tests/unit/shared/conftest.py @@ -0,0 +1,157 @@ +# Copyright (c) 2012-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the Cenobit Technologies nor the names of +# its contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +from io import BytesIO +import os +import sys +import typing as t + +from flask.testing import EnvironBuilder + +import pytest +import requests +from werkzeug.test import run_wsgi_app +from urllib3.response import HTTPResponse +from requests.adapters import BaseAdapter, HTTPAdapter as RequestsHTTPAdapter +from werkzeug.datastructures import Headers + +# Python 3.11+ +try: + from typing import Self +except ImportError: # pragma: no cover + from typing_extensions import Self + +if t.TYPE_CHECKING: + from flask import Flask + + from pytest import MonkeyPatch + from requests import Response, PreparedRequest + + +class HTTPAdapter(RequestsHTTPAdapter): + def init_poolmanager(self: Self, connections: int, maxsize: int, block: bool = False, **pool_kwargs: t.Any) -> None: # noqa: ANN401 + self.poolmanager = None + + +class FlaskClientAdapter(BaseAdapter): + def __init__(self: Self, app: 'Flask', path: str = '/', base_url: t.Optional[str] = None) -> None: + self.app = app + self.path = path + self.base_url = base_url + self.http_adapter = HTTPAdapter() + self.environ_base = {'REMOTE_ADDR': '127.0.0.1', 'HTTP_USER_AGENT': 'FlaskClientAdapter/0.0.1'} + + def _build_response(self: Self, req: 'PreparedRequest', resp: tuple[t.Iterable[bytes], str, Headers]) -> 'Response': + rv, status_code, headers = resp + if rv: + fh = BytesIO() + for chunk in rv: + fh.write(chunk) + fh.seek(0) + rv = fh + else: + rv = BytesIO() + + code, reason = status_code.split(None, 1) + resp_wrapped = HTTPResponse(body=rv, headers=headers, status=int(code), reason=reason, preload_content=False) + + return self.http_adapter.build_response(req, resp_wrapped) + + def send(self: Self, request: 'PreparedRequest', **kwargs: t.Any) -> 'Response': # noqa: ANN401 + kwargs = { + 'environ_base': self.environ_base, + 'method': request.method, + 'data': request.body, + 'headers': request.headers.items(), + } + builder = EnvironBuilder(app=self.app, path=request.path_url, **kwargs) + if self.base_url: + builder.base_url = self.base_url + + try: + environ = builder.get_environ() + finally: + builder.close() + + resp = run_wsgi_app(self.app, environ, buffered=True) + return self._build_response(request, resp) + + def close(self: Self) -> None: + pass + + +@pytest.fixture(scope='session') +def api_url() -> str: + return 'http://localhost/api' + + +@pytest.fixture(scope='function') +def test_apps(monkeypatch: 'MonkeyPatch') -> t.Generator[None, None, None]: + monkeypatch.syspath_prepend(os.path.join(os.path.dirname(__file__), os.pardir, os.pardir, 'test_apps')) + original_modules = set(sys.modules.keys()) + + yield None + + # Remove any imports cached during the test. Otherwise "import app" + # will work in the next test even though it's no longer on the path. + for key in sys.modules.keys() - original_modules: + sys.modules.pop(key) + + +@pytest.fixture(scope='function') +def app(test_apps: 't.Generator[MonkeyPatch]') -> 't.Generator[Flask]': + """Create and configure a new app instance for each test.""" + from app.app import create_app # type: ignore + + flask_app = create_app({'TESTING': True}) + yield flask_app + + +@pytest.fixture(scope='function') +def async_app(test_apps: 't.Generator[MonkeyPatch]') -> 't.Generator[Flask]': + """Create and configure a new async app instance for each test.""" + from async_app.app import create_app # type: ignore + + flask_app = create_app({'TESTING': True}) + yield flask_app + + +@pytest.fixture(scope='function') +def session(app: 'Flask') -> t.Generator[requests.Session, None, None]: + """A test client for the app.""" + session = requests.Session() + session.verify = False + session.mount('http://', FlaskClientAdapter(app=app)) + yield session + + +@pytest.fixture(scope='function') +def async_session(async_app: 'Flask') -> t.Generator[requests.Session, None, None]: + """A test async client for the app.""" + session = requests.Session() + session.verify = False + session.mount('http://', FlaskClientAdapter(app=async_app)) + yield session diff --git a/tests/unit/shared/test_apps b/tests/unit/shared/test_apps new file mode 120000 index 00000000..1834ac43 --- /dev/null +++ b/tests/unit/shared/test_apps @@ -0,0 +1 @@ +../../shared/test_apps \ No newline at end of file diff --git a/tests/unit/test_app.py b/tests/unit/test_app.py index 2d52d6d5..928ae85d 100644 --- a/tests/unit/test_app.py +++ b/tests/unit/test_app.py @@ -33,7 +33,7 @@ import pytest from werkzeug.datastructures import Headers -from flask_jsonrpc import JSONRPC, JSONRPCBlueprint +from flask_jsonrpc import JSONRPC # Python 3.10+ try: @@ -43,7 +43,7 @@ class CustomException(Exception): - def __init__(self: Self, message: str, data: t.Dict[str, t.Any]) -> None: + def __init__(self: Self, message: str, data: dict[str, t.Any]) -> None: super().__init__(message) self.message = message self.data = data @@ -53,31 +53,25 @@ def test_app_create() -> None: app = Flask('test_app', instance_relative_config=True) jsonrpc = JSONRPC(app, '/api', enable_web_browsable_api=True) - # pylint: disable=W0612 @jsonrpc.method('app.index') def index() -> str: return 'Welcome to Flask JSON-RPC' - # pylint: disable=W0612 @jsonrpc.method('app.fn0') def fn0() -> None: pass - # pylint: disable=W0612 @jsonrpc.method('app.fn1') def fn1() -> str: return 'Bar' - # pylint: disable=W0612 @jsonrpc.method('app.fn2') def fn2(s: str) -> str: return f'Foo {s}' - # pylint: disable=W0612 def fn3(s: str) -> str: return f'Foo {s}' - # pylint: disable=W0612 @jsonrpc.method('app.fn4', notification=False) def fn4(s: str) -> str: return f'Goo {s}' @@ -155,15 +149,13 @@ def test_app_create_using_error_handler() -> None: jsonrpc = JSONRPC(app, '/api', enable_web_browsable_api=True) @jsonrpc.errorhandler(CustomException) - def handle_custom_exc(exc: CustomException) -> t.Dict[str, t.Any]: + def handle_custom_exc(exc: CustomException) -> dict[str, t.Any]: return exc.data - # pylint: disable=W0612 @jsonrpc.method('app.index') def index() -> str: return 'Welcome to Flask JSON-RPC' - # pylint: disable=W0612 @jsonrpc.method('app.errorhandler') def fn0() -> t.NoReturn: raise CustomException('Testing error handler', data={'message': 'Flask JSON-RPC', 'code': '0000'}) @@ -187,86 +179,11 @@ def fn0() -> t.NoReturn: assert rv.status_code == 500 -def test_app_create_modular_using_error_handler() -> None: - jsonrpc_api_1 = JSONRPCBlueprint('jsonrpc_api_1', __name__) - - @jsonrpc_api_1.errorhandler(CustomException) - def handle_custom_exc_jsonrpc_api_1(exc: CustomException) -> str: - return f"jsonrpc_api_1: {exc.data['message']}" - - # pylint: disable=W0612 - @jsonrpc_api_1.method('blue1.index') - def index_b1() -> str: - return 'b1 index' - - # pylint: disable=W0612 - @jsonrpc_api_1.method('blue1.errorhandler') - def error_b1() -> t.NoReturn: - raise CustomException('Testing error handler', data={'message': 'Flask JSON-RPC', 'code': '0000'}) - - jsonrpc_api_2 = JSONRPCBlueprint('jsonrpc_api_2', __name__) - - @jsonrpc_api_2.errorhandler(CustomException) - def handle_custom_exc_jsonrpc_api_2(exc: CustomException) -> str: - return f"jsonrpc_api_2: {exc.data['message']}" - - # pylint: disable=W0612 - @jsonrpc_api_2.method('blue2.index') - def index_b2() -> str: - return 'b2 index' - - # pylint: disable=W0612 - @jsonrpc_api_2.method('blue2.errorhandler') - def error_b2() -> t.NoReturn: - raise CustomException('Testing error handler', data={'message': 'Flask JSON-RPC', 'code': '0000'}) - - app = Flask('test_app', instance_relative_config=True) - jsonrpc = JSONRPC(app, '/api', enable_web_browsable_api=True) - jsonrpc.register_blueprint(app, jsonrpc_api_1, url_prefix='/b1') - jsonrpc.register_blueprint(app, jsonrpc_api_2, url_prefix='/b2') - - with app.test_client() as client: - rv = client.post('/api/b1', json={'id': 1, 'jsonrpc': '2.0', 'method': 'blue1.index', 'params': []}) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'b1 index'} - assert rv.status_code == 200 - - rv = client.post('/api/b1', json={'id': 1, 'jsonrpc': '2.0', 'method': 'blue1.errorhandler', 'params': []}) - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32000, - 'data': 'jsonrpc_api_1: Flask JSON-RPC', - 'message': 'Server error', - 'name': 'ServerError', - }, - } - assert rv.status_code == 500 - - rv = client.post('/api/b2', json={'id': 1, 'jsonrpc': '2.0', 'method': 'blue2.index', 'params': []}) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'b2 index'} - assert rv.status_code == 200 - - rv = client.post('/api/b2', json={'id': 1, 'jsonrpc': '2.0', 'method': 'blue2.errorhandler', 'params': []}) - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32000, - 'data': 'jsonrpc_api_2: Flask JSON-RPC', - 'message': 'Server error', - 'name': 'ServerError', - }, - } - assert rv.status_code == 500 - - def test_app_create_with_server_name() -> None: app = Flask('test_app', instance_relative_config=True) app.config.update({'SERVER_NAME': 'domain:80'}) jsonrpc = JSONRPC(app, '/api', enable_web_browsable_api=True) - # pylint: disable=W0612 @jsonrpc.method('app.index') def index() -> str: return 'Welcome to Flask JSON-RPC' @@ -281,7 +198,6 @@ def test_app_create_without_register_app() -> None: app = Flask('test_app', instance_relative_config=True) jsonrpc = JSONRPC(service_url='/api', enable_web_browsable_api=True) - # pylint: disable=W0612 @jsonrpc.method('app.fn2') def fn1(s: str) -> str: return f'Foo {s}' @@ -308,20 +224,19 @@ def test_app_create_with_method_without_annotation() -> None: jsonrpc = JSONRPC(app, '/api', enable_web_browsable_api=True) with pytest.raises(ValueError, match='no type annotations present to: app.fn1'): - # pylint: disable=W0612 + @jsonrpc.method('app.fn1') def fn1(s): # noqa: ANN001,ANN202 return f'Foo {s}' - # pylint: disable=W0612 @jsonrpc.method('app.fn2') def fn2(s: str) -> str: return f'Bar {s}' with pytest.raises(ValueError, match='no type annotations present to: app.fn3'): - # pylint: disable=W0612 + @jsonrpc.method('app.fn3') - def fn3(s): # pylint: disable=W0612 # noqa: ANN001,ANN202 + def fn3(s): # noqa: ANN001,ANN202 return f'Poo {s}' @@ -329,26 +244,24 @@ def test_app_create_with_method_without_annotation_on_params() -> None: app = Flask('test_app', instance_relative_config=True) jsonrpc = JSONRPC(app, '/api', enable_web_browsable_api=True) - # pylint: disable=W0612 @jsonrpc.method('app.fn4') def fn4() -> None: pass with pytest.raises(ValueError, match='no type annotations present to: app.fn2'): - # pylint: disable=W0612 + @jsonrpc.method('app.fn2') def fn2(s) -> str: # noqa: ANN001 return f'Foo {s}' - # pylint: disable=W0612 @jsonrpc.method('app.fn1') def fn1(s: str) -> str: return f'Bar {s}' with pytest.raises(ValueError, match='no type annotations present to: app.fn3'): - # pylint: disable=W0612 + @jsonrpc.method('app.fn3') - def fn3(s): # pylint: disable=W0612 # noqa: ANN001,ANN202 + def fn3(s): # noqa: ANN001,ANN202 return f'Poo {s}' @@ -356,17 +269,14 @@ def test_app_create_with_method_without_annotation_on_return() -> None: app = Flask('test_app', instance_relative_config=True) jsonrpc = JSONRPC(app, '/api', enable_web_browsable_api=True) - # pylint: disable=W0612 @jsonrpc.method('app.fn1') def fn1(s: str): # noqa: ANN202 return f'Foo {s}' - # pylint: disable=W0612 @jsonrpc.method('app.fn2') def fn2(s: str) -> str: return f'Bar {s}' - # pylint: disable=W0612 @jsonrpc.method('app.fn3') def fn3(s: str) -> t.NoReturn: raise ValueError(f'no return: {s}') @@ -408,7 +318,7 @@ def test_app_create_with_wrong_return() -> None: jsonrpc = JSONRPC(app, '/api', enable_web_browsable_api=True) @jsonrpc.method('app.fn1') - def fn2(s: str) -> t.Tuple[str, int, int, int]: # pylint: disable=W0612 + def fn2(s: str) -> tuple[str, int, int, int]: return f'Bar {s}', 1, 2, 3 with app.test_client() as client: @@ -435,7 +345,6 @@ def test_app_create_with_invalid_view_func() -> None: app = Flask('test_app', instance_relative_config=True) jsonrpc = JSONRPC(app, service_url='/api', enable_web_browsable_api=True) - # pylint: disable=W0612 @jsonrpc.method('app.fn2') def fn1(s: str) -> str: return f'Foo {s}' @@ -454,33 +363,27 @@ def test_app_create_multiple_jsonrpc_versions() -> None: jsonrpc_v1 = JSONRPC(app, '/api/v1', enable_web_browsable_api=True) jsonrpc_v2 = JSONRPC(app, '/api/v2', enable_web_browsable_api=True) - # pylint: disable=W0612 @jsonrpc_v1.method('app.fn2') def fn1_v1(s: str) -> str: return f'v1: Foo {s}' - # pylint: disable=W0612 @jsonrpc_v2.method('app.fn2') def fn1_v2(s: str) -> str: return f'v2: Foo {s}' - # pylint: disable=W0612 @jsonrpc_v1.method('app.fn3') def fn3(s: str) -> str: return f'Poo {s}' - # pylint: disable=W0612 @jsonrpc_v2.method('app.fn1') def fn2(s: str) -> str: return f'Bar {s}' - # pylint: disable=W0612 def fn4_v1(s: str) -> str: return f'Poo {s}' jsonrpc_v1.register(fn4_v1) - # pylint: disable=W0612 def fn4_v2(s: str) -> str: return f'Bar {s}' @@ -512,110 +415,49 @@ def fn4_v2(s: str) -> str: assert rv.status_code == 200 -def test_app_create_modular_apps() -> None: - jsonrpc_api_1 = JSONRPCBlueprint('jsonrpc_api_1', __name__) - - # pylint: disable=W0612 - @jsonrpc_api_1.method('blue1.fn2') - def fn1_b1(s: str) -> str: - return f'b1: Foo {s}' - - jsonrpc_api_2 = JSONRPCBlueprint('jsonrpc_api_2', __name__) - - # pylint: disable=W0612 - @jsonrpc_api_2.method('blue2.fn2') - def fn1_b2(s: str) -> str: - return f'b2: Foo {s}' - - # pylint: disable=W0612 - @jsonrpc_api_2.method('blue2.fn1') - def fn2_b2(s: str) -> str: - return f'b2: Bar {s}' - - jsonrpc_api_3 = JSONRPCBlueprint('jsonrpc_api_3', __name__) - - # pylint: disable=W0612 - @jsonrpc_api_3.method('blue3.fn2') - def fn1_b3(s: str) -> str: - return f'b3: Foo {s}' - - app = Flask('test_app', instance_relative_config=True) - jsonrpc = JSONRPC(app, '/api', enable_web_browsable_api=True) - jsonrpc.register_blueprint(app, jsonrpc_api_1, url_prefix='/b1') - jsonrpc.register_blueprint(app, jsonrpc_api_2, url_prefix='/b2') - jsonrpc.register_blueprint(app, jsonrpc_api_3, url_prefix='/b3') - - with app.test_client() as client: - rv = client.post('/api/b1', json={'id': 1, 'jsonrpc': '2.0', 'method': 'blue1.fn2', 'params': [':)']}) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'b1: Foo :)'} - assert rv.status_code == 200 - - rv = client.post('/api/b2', json={'id': 1, 'jsonrpc': '2.0', 'method': 'blue2.fn2', 'params': [':)']}) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'b2: Foo :)'} - assert rv.status_code == 200 - - rv = client.post('/api/b2', json={'id': 1, 'jsonrpc': '2.0', 'method': 'blue2.fn1', 'params': [':)']}) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'b2: Bar :)'} - assert rv.status_code == 200 - - rv = client.post('/api/b3', json={'id': 1, 'jsonrpc': '2.0', 'method': 'blue3.fn2', 'params': [':)']}) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'b3: Foo :)'} - assert rv.status_code == 200 - - -# pylint: disable=R0915 def test_app_create_with_rcp_batch() -> None: app = Flask('test_app', instance_relative_config=True) jsonrpc = JSONRPC(app, '/api', enable_web_browsable_api=True) - # pylint: disable=W0612 @jsonrpc.method('sum') def sum_(a: int, b: int) -> int: return a + b - # pylint: disable=W0612 @jsonrpc.method('subtract') def subtract(a: int, b: int) -> int: return a - b - # pylint: disable=W0612 @jsonrpc.method('get_user') - def get_user(uid: str) -> t.Dict[str, t.Any]: + def get_user(uid: str) -> dict[str, t.Any]: return {'uid': uid, 'name': 'John Dee'} - # pylint: disable=W0612 @jsonrpc.method('notify_sum') - def notify_sum(numbers: t.List[int]) -> int: + def notify_sum(numbers: list[int]) -> int: s = sum(x**2 for x in numbers) return s - # pylint: disable=W0612 @jsonrpc.method('headers1') - def headers1() -> t.Tuple[float, int, t.List[t.Tuple[str, t.Any]]]: + def headers1() -> tuple[float, int, list[tuple[str, t.Any]]]: return 3.141592653589793, 200, [('X-Header-1-a', 'a1'), ('X-Header-1-b', 'b1')] - # pylint: disable=W0612 @jsonrpc.method('headers2') - def headers2() -> t.Tuple[float, int, t.Tuple[str, t.Any]]: + def headers2() -> tuple[float, int, tuple[str, t.Any]]: return 3.141592653589793, 201, ('X-Header-2-a', 'a2') - # pylint: disable=W0612 @jsonrpc.method('headers3') - def headers3() -> t.Tuple[float, int, Headers]: + def headers3() -> tuple[float, int, Headers]: headers = Headers() headers.set('X-Header-3-a', 'a3') headers.set('X-Header-3-b', 'b3') headers.set('X-Header-3-c', 'c3') return 3.141592653589793, 200, headers - # pylint: disable=W0612 @jsonrpc.method('headers4') - def headers4() -> t.Tuple[float, int, t.Dict[str, t.Any]]: + def headers4() -> tuple[float, int, dict[str, t.Any]]: return 3.141592653589793, 200, {'X-Header-4-a': 'a4', 'X-Header-4-b': 'b4'} - # pylint: disable=W0612 @jsonrpc.method('headers_duplicate') - def headers_duplicate() -> t.Tuple[float, int, t.Dict[str, t.Any]]: + def headers_duplicate() -> tuple[float, int, dict[str, t.Any]]: return ( 3.141592653589793, 400, diff --git a/tests/unit/test_async_app.py b/tests/unit/test_async_app.py index f04ca7bd..6e9e1875 100644 --- a/tests/unit/test_async_app.py +++ b/tests/unit/test_async_app.py @@ -34,7 +34,7 @@ import pytest from werkzeug.datastructures import Headers -from flask_jsonrpc import JSONRPC, JSONRPCBlueprint +from flask_jsonrpc import JSONRPC # Python 3.10+ try: @@ -46,7 +46,7 @@ class CustomException(Exception): - def __init__(self: Self, message: str, data: t.Dict[str, t.Any]) -> None: + def __init__(self: Self, message: str, data: dict[str, t.Any]) -> None: super().__init__(message) self.message = message self.data = data @@ -56,35 +56,29 @@ def test_app_create() -> None: app = Flask('test_app', instance_relative_config=True) jsonrpc = JSONRPC(app, '/api', enable_web_browsable_api=True) - # pylint: disable=W0612 @jsonrpc.method('app.index') async def index() -> str: await asyncio.sleep(0) return 'Welcome to Flask JSON-RPC' - # pylint: disable=W0612 @jsonrpc.method('app.fn0') async def fn0() -> None: await asyncio.sleep(0) - # pylint: disable=W0612 @jsonrpc.method('app.fn1') async def fn1() -> str: await asyncio.sleep(0) return 'Bar' - # pylint: disable=W0612 @jsonrpc.method('app.fn2') async def fn2(s: str) -> str: await asyncio.sleep(0) return f'Foo {s}' - # pylint: disable=W0612 async def fn3(s: str) -> str: await asyncio.sleep(0) return f'Foo {s}' - # pylint: disable=W0612 @jsonrpc.method('app.fn4', notification=False) async def fn4(s: str) -> str: await asyncio.sleep(0) @@ -163,17 +157,15 @@ def test_app_create_using_error_handler() -> None: jsonrpc = JSONRPC(app, '/api', enable_web_browsable_api=True) @jsonrpc.errorhandler(CustomException) - async def handle_custom_exc(exc: CustomException) -> t.Dict[str, t.Any]: + async def handle_custom_exc(exc: CustomException) -> dict[str, t.Any]: await asyncio.sleep(0) return exc.data - # pylint: disable=W0612 @jsonrpc.method('app.index') async def index() -> str: await asyncio.sleep(0) return 'Welcome to Flask JSON-RPC' - # pylint: disable=W0612 @jsonrpc.method('app.errorhandler') async def fn0() -> t.NoReturn: await asyncio.sleep(0) @@ -198,92 +190,11 @@ async def fn0() -> t.NoReturn: assert rv.status_code == 500 -def test_app_create_modular_using_error_handler() -> None: - jsonrpc_api_1 = JSONRPCBlueprint('jsonrpc_api_1', __name__) - - @jsonrpc_api_1.errorhandler(CustomException) - async def handle_custom_exc_jsonrpc_api_1(exc: CustomException) -> t.Dict[str, t.Any]: - await asyncio.sleep(0) - return f"jsonrpc_api_1: {exc.data['message']}" - - # pylint: disable=W0612 - @jsonrpc_api_1.method('blue1.index') - async def index_b1() -> str: - await asyncio.sleep(0) - return 'b1 index' - - # pylint: disable=W0612 - @jsonrpc_api_1.method('blue1.errorhandler') - async def error_b1() -> t.NoReturn: - await asyncio.sleep(0) - raise CustomException('Testing error handler', data={'message': 'Flask JSON-RPC', 'code': '0000'}) - - jsonrpc_api_2 = JSONRPCBlueprint('jsonrpc_api_2', __name__) - - @jsonrpc_api_2.errorhandler(CustomException) - async def handle_custom_exc_jsonrpc_api_2(exc: CustomException) -> t.Dict[str, t.Any]: - await asyncio.sleep(0) - return f"jsonrpc_api_2: {exc.data['message']}" - - # pylint: disable=W0612 - @jsonrpc_api_2.method('blue2.index') - async def index_b2() -> str: - await asyncio.sleep(0) - return 'b2 index' - - # pylint: disable=W0612 - @jsonrpc_api_2.method('blue2.errorhandler') - async def error_b2() -> t.NoReturn: - await asyncio.sleep(0) - raise CustomException('Testing error handler', data={'message': 'Flask JSON-RPC', 'code': '0000'}) - - app = Flask('test_app', instance_relative_config=True) - jsonrpc = JSONRPC(app, '/api', enable_web_browsable_api=True) - jsonrpc.register_blueprint(app, jsonrpc_api_1, url_prefix='/b1') - jsonrpc.register_blueprint(app, jsonrpc_api_2, url_prefix='/b2') - - with app.test_client() as client: - rv = client.post('/api/b1', json={'id': 1, 'jsonrpc': '2.0', 'method': 'blue1.index', 'params': []}) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'b1 index'} - assert rv.status_code == 200 - - rv = client.post('/api/b1', json={'id': 1, 'jsonrpc': '2.0', 'method': 'blue1.errorhandler', 'params': []}) - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32000, - 'data': 'jsonrpc_api_1: Flask JSON-RPC', - 'message': 'Server error', - 'name': 'ServerError', - }, - } - assert rv.status_code == 500 - - rv = client.post('/api/b2', json={'id': 1, 'jsonrpc': '2.0', 'method': 'blue2.index', 'params': []}) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'b2 index'} - assert rv.status_code == 200 - - rv = client.post('/api/b2', json={'id': 1, 'jsonrpc': '2.0', 'method': 'blue2.errorhandler', 'params': []}) - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32000, - 'data': 'jsonrpc_api_2: Flask JSON-RPC', - 'message': 'Server error', - 'name': 'ServerError', - }, - } - assert rv.status_code == 500 - - def test_app_create_with_server_name() -> None: app = Flask('test_app', instance_relative_config=True) app.config.update({'SERVER_NAME': 'domain:80'}) jsonrpc = JSONRPC(app, '/api', enable_web_browsable_api=True) - # pylint: disable=W0612 @jsonrpc.method('app.index') async def index() -> str: await asyncio.sleep(0) @@ -299,7 +210,6 @@ def test_app_create_without_register_app() -> None: app = Flask('test_app', instance_relative_config=True) jsonrpc = JSONRPC(service_url='/api', enable_web_browsable_api=True) - # pylint: disable=W0612 @jsonrpc.method('app.fn2') async def fn1(s: str) -> str: await asyncio.sleep(0) @@ -318,22 +228,21 @@ def test_app_create_with_method_without_annotation() -> None: jsonrpc = JSONRPC(app, '/api', enable_web_browsable_api=True) with pytest.raises(ValueError, match='no type annotations present to: app.fn1'): - # pylint: disable=W0612 + @jsonrpc.method('app.fn1') async def fn1(s): # noqa: ANN001,ANN202 await asyncio.sleep(0) return f'Foo {s}' - # pylint: disable=W0612 @jsonrpc.method('app.fn2') async def fn2(s: str) -> str: await asyncio.sleep(0) return f'Bar {s}' with pytest.raises(ValueError, match='no type annotations present to: app.fn3'): - # pylint: disable=W0612 + @jsonrpc.method('app.fn3') - async def fn3(s): # pylint: disable=W0612 # noqa: ANN001,ANN202 + async def fn3(s): # noqa: ANN001,ANN202 await asyncio.sleep(0) return f'Poo {s}' @@ -342,28 +251,26 @@ def test_app_create_with_method_without_annotation_on_params() -> None: app = Flask('test_app', instance_relative_config=True) jsonrpc = JSONRPC(app, '/api', enable_web_browsable_api=True) - # pylint: disable=W0612 @jsonrpc.method('app.fn4') async def fn4() -> None: await asyncio.sleep(0) with pytest.raises(ValueError, match='no type annotations present to: app.fn2'): - # pylint: disable=W0612 + @jsonrpc.method('app.fn2') async def fn2(s) -> str: # noqa: ANN001 await asyncio.sleep(0) return f'Foo {s}' - # pylint: disable=W0612 @jsonrpc.method('app.fn1') async def fn1(s: str) -> str: await asyncio.sleep(0) return f'Bar {s}' with pytest.raises(ValueError, match='no type annotations present to: app.fn3'): - # pylint: disable=W0612 + @jsonrpc.method('app.fn3') - async def fn3(s): # pylint: disable=W0612 # noqa: ANN001,ANN202 + async def fn3(s): # noqa: ANN001,ANN202 await asyncio.sleep(0) return f'Poo {s}' @@ -372,19 +279,16 @@ def test_app_create_with_method_without_annotation_on_return() -> None: app = Flask('test_app', instance_relative_config=True) jsonrpc = JSONRPC(app, '/api', enable_web_browsable_api=True) - # pylint: disable=W0612 @jsonrpc.method('app.fn1') async def fn1(s: str): # noqa: ANN202 await asyncio.sleep(0) return f'Foo {s}' - # pylint: disable=W0612 @jsonrpc.method('app.fn2') async def fn2(s: str) -> str: await asyncio.sleep(0) return f'Bar {s}' - # pylint: disable=W0612 @jsonrpc.method('app.fn3') async def fn3(s: str) -> t.NoReturn: await asyncio.sleep(0) @@ -427,7 +331,7 @@ def test_app_create_with_wrong_return() -> None: jsonrpc = JSONRPC(app, '/api', enable_web_browsable_api=True) @jsonrpc.method('app.fn1') - async def fn2(s: str) -> t.Tuple[str, int, int, int]: # pylint: disable=W0612 + async def fn2(s: str) -> tuple[str, int, int, int]: await asyncio.sleep(0) return f'Bar {s}', 1, 2, 3 @@ -455,7 +359,6 @@ def test_app_create_with_invalid_view_func() -> None: app = Flask('test_app', instance_relative_config=True) jsonrpc = JSONRPC(app, service_url='/api', enable_web_browsable_api=True) - # pylint: disable=W0612 @jsonrpc.method('app.fn2') async def fn1(s: str) -> str: await asyncio.sleep(0) @@ -475,38 +378,32 @@ def test_app_create_multiple_jsonrpc_versions() -> None: jsonrpc_v1 = JSONRPC(app, '/api/v1', enable_web_browsable_api=True) jsonrpc_v2 = JSONRPC(app, '/api/v2', enable_web_browsable_api=True) - # pylint: disable=W0612 @jsonrpc_v1.method('app.fn2') async def fn1_v1(s: str) -> str: await asyncio.sleep(0) return f'v1: Foo {s}' - # pylint: disable=W0612 @jsonrpc_v2.method('app.fn2') async def fn1_v2(s: str) -> str: await asyncio.sleep(0) return f'v2: Foo {s}' - # pylint: disable=W0612 @jsonrpc_v1.method('app.fn3') async def fn3(s: str) -> str: await asyncio.sleep(0) return f'Poo {s}' - # pylint: disable=W0612 @jsonrpc_v2.method('app.fn1') async def fn2(s: str) -> str: await asyncio.sleep(0) return f'Bar {s}' - # pylint: disable=W0612 async def fn4_v1(s: str) -> str: await asyncio.sleep(0) return f'Poo {s}' jsonrpc_v1.register(fn4_v1) - # pylint: disable=W0612 async def fn4_v2(s: str) -> str: await asyncio.sleep(0) return f'Bar {s}' @@ -539,108 +436,43 @@ async def fn4_v2(s: str) -> str: assert rv.status_code == 200 -def test_app_create_modular_apps() -> None: - jsonrpc_api_1 = JSONRPCBlueprint('jsonrpc_api_1', __name__) - - # pylint: disable=W0612 - @jsonrpc_api_1.method('blue1.fn2') - async def fn1_b1(s: str) -> str: - await asyncio.sleep(0) - return f'b1: Foo {s}' - - jsonrpc_api_2 = JSONRPCBlueprint('jsonrpc_api_2', __name__) - - # pylint: disable=W0612 - @jsonrpc_api_2.method('blue2.fn2') - async def fn1_b2(s: str) -> str: - await asyncio.sleep(0) - return f'b2: Foo {s}' - - # pylint: disable=W0612 - @jsonrpc_api_2.method('blue2.fn1') - async def fn2_b2(s: str) -> str: - await asyncio.sleep(0) - return f'b2: Bar {s}' - - jsonrpc_api_3 = JSONRPCBlueprint('jsonrpc_api_3', __name__) - - # pylint: disable=W0612 - @jsonrpc_api_3.method('blue3.fn2') - async def fn1_b3(s: str) -> str: - await asyncio.sleep(0) - return f'b3: Foo {s}' - - app = Flask('test_app', instance_relative_config=True) - jsonrpc = JSONRPC(app, '/api', enable_web_browsable_api=True) - jsonrpc.register_blueprint(app, jsonrpc_api_1, url_prefix='/b1') - jsonrpc.register_blueprint(app, jsonrpc_api_2, url_prefix='/b2') - jsonrpc.register_blueprint(app, jsonrpc_api_3, url_prefix='/b3') - - with app.test_client() as client: - rv = client.post('/api/b1', json={'id': 1, 'jsonrpc': '2.0', 'method': 'blue1.fn2', 'params': [':)']}) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'b1: Foo :)'} - assert rv.status_code == 200 - - rv = client.post('/api/b2', json={'id': 1, 'jsonrpc': '2.0', 'method': 'blue2.fn2', 'params': [':)']}) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'b2: Foo :)'} - assert rv.status_code == 200 - - rv = client.post('/api/b2', json={'id': 1, 'jsonrpc': '2.0', 'method': 'blue2.fn1', 'params': [':)']}) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'b2: Bar :)'} - assert rv.status_code == 200 - - rv = client.post('/api/b3', json={'id': 1, 'jsonrpc': '2.0', 'method': 'blue3.fn2', 'params': [':)']}) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'b3: Foo :)'} - assert rv.status_code == 200 - - -# pylint: disable=R0915 - - def test_app_create_with_rcp_batch() -> None: app = Flask('test_app', instance_relative_config=True) jsonrpc = JSONRPC(app, '/api', enable_web_browsable_api=True) - # pylint: disable=W0612 @jsonrpc.method('sum') async def sum_(a: int, b: int) -> int: await asyncio.sleep(0) return a + b - # pylint: disable=W0612 @jsonrpc.method('subtract') async def subtract(a: int, b: int) -> int: await asyncio.sleep(0) return a - b - # pylint: disable=W0612 @jsonrpc.method('get_user') - async def get_user(uid: str) -> t.Dict[str, t.Any]: + async def get_user(uid: str) -> dict[str, t.Any]: await asyncio.sleep(0) return {'uid': uid, 'name': 'John Dee'} - # pylint: disable=W0612 @jsonrpc.method('notify_sum') - async def notify_sum(numbers: t.List[int]) -> int: + async def notify_sum(numbers: list[int]) -> int: await asyncio.sleep(0) s = sum(x**2 for x in numbers) return s - # pylint: disable=W0612 @jsonrpc.method('headers1') - async def headers1() -> t.Tuple[float, int, t.List[t.Tuple[str, t.Any]]]: + async def headers1() -> tuple[float, int, list[tuple[str, t.Any]]]: await asyncio.sleep(0) return 3.141592653589793, 200, [('X-Header-1-a', 'a1'), ('X-Header-1-b', 'b1')] - # pylint: disable=W0612 @jsonrpc.method('headers2') - async def headers2() -> t.Tuple[float, int, t.Tuple[str, t.Any]]: + async def headers2() -> tuple[float, int, tuple[str, t.Any]]: await asyncio.sleep(0) return 3.141592653589793, 201, ('X-Header-2-a', 'a2') - # pylint: disable=W0612 @jsonrpc.method('headers3') - async def headers3() -> t.Tuple[float, int, Headers]: + async def headers3() -> tuple[float, int, Headers]: await asyncio.sleep(0) headers = Headers() headers.set('X-Header-3-a', 'a3') @@ -648,15 +480,13 @@ async def headers3() -> t.Tuple[float, int, Headers]: headers.set('X-Header-3-c', 'c3') return 3.141592653589793, 200, headers - # pylint: disable=W0612 @jsonrpc.method('headers4') - async def headers4() -> t.Tuple[float, int, t.Dict[str, t.Any]]: + async def headers4() -> tuple[float, int, dict[str, t.Any]]: await asyncio.sleep(0) return 3.141592653589793, 200, {'X-Header-4-a': 'a4', 'X-Header-4-b': 'b4'} - # pylint: disable=W0612 @jsonrpc.method('headers_duplicate') - async def headers_duplicate() -> t.Tuple[float, int, t.Dict[str, t.Any]]: + async def headers_duplicate() -> tuple[float, int, dict[str, t.Any]]: await asyncio.sleep(0) return ( 3.141592653589793, diff --git a/tests/unit/test_async_client.py b/tests/unit/test_async_client.py deleted file mode 100644 index b5104647..00000000 --- a/tests/unit/test_async_client.py +++ /dev/null @@ -1,1303 +0,0 @@ -# Copyright (c) 2020-2024, Cenobit Technologies, Inc. http://cenobit.es/ -# All rights reserved. -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: -# -# * Redistributions of source code must retain the above copyright notice, -# this list of conditions and the following disclaimer. -# * Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# * Neither the name of the Cenobit Technologies nor the names of -# its contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE -# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. -import json -import typing as t - -import pytest - -if t.TYPE_CHECKING: - from flask.templating import FlaskClient - -pytest.importorskip('asgiref') - - -def test_app_greeting(async_client: 'FlaskClient') -> None: - rv = async_client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting'}) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Flask JSON-RPC'} - assert rv.status_code == 200 - - rv = async_client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['Python']}) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Python'} - assert rv.status_code == 200 - - rv = async_client.post( - '/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': {'name': 'Flask'}} - ) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Flask'} - assert rv.status_code == 200 - - rv = async_client.post( - '/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': {'name': 1}} - ) - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32602, - 'data': {'message': 'argument "name" (int) is not an instance of str'}, - 'message': 'Invalid params', - 'name': 'InvalidParamsError', - }, - } - assert rv.status_code == 400 - - -def test_app_greeting_with_different_content_types(async_client: 'FlaskClient') -> None: - rv = async_client.post( - '/api', - data=json.dumps({'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting'}), - headers={'Content-Type': 'application/json-rpc'}, - ) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Flask JSON-RPC'} - assert rv.status_code == 200 - - rv = async_client.post( - '/api', - data=json.dumps({'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['Python']}), - headers={'Content-Type': 'application/jsonrequest'}, - ) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Python'} - assert rv.status_code == 200 - - rv = async_client.post( - '/api', - data=json.dumps({'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': {'name': 'Flask'}}), - headers={'Content-Type': 'application/json'}, - ) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Flask'} - assert rv.status_code == 200 - - -def test_app_greeting_raise_parse_error(async_client: 'FlaskClient') -> None: - rv = async_client.post('/api', data={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting'}) - assert rv.json == { - 'id': None, - 'jsonrpc': '2.0', - 'error': { - 'code': -32700, - 'data': { - 'message': 'Invalid mime type for JSON: application/x-www-form-urlencoded, ' - 'use header Content-Type: application/json' - }, - 'message': 'Parse error', - 'name': 'ParseError', - }, - } - assert rv.status_code == 400 - - rv = async_client.post( - '/api', - data="{'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting'}", - headers={'Content-Type': 'application/json'}, - ) - assert rv.json == { - 'id': None, - 'jsonrpc': '2.0', - 'error': { - 'code': -32700, - 'data': {'message': "Invalid JSON: b\"{'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting'}\""}, - 'message': 'Parse error', - 'name': 'ParseError', - }, - } - assert rv.status_code == 400 - - rv = async_client.post( - '/api', - data="""[ - {'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['Flask'], 'id': '1'}, - {'jsonrpc': '2.0', 'method' - ]""", - headers={'Content-Type': 'application/json'}, - ) - assert rv.json == { - 'id': None, - 'jsonrpc': '2.0', - 'error': { - 'code': -32700, - 'data': { - 'message': "Invalid JSON: b\"[\\n {'jsonrpc': " - "'2.0', 'method': 'jsonrpc.greeting', 'params': " - "['Flask'], 'id': '1'},\\n " - "{'jsonrpc': '2.0', 'method'\\n " - ']"' - }, - 'message': 'Parse error', - 'name': 'ParseError', - }, - } - assert rv.status_code == 400 - - -def test_app_greeting_raise_invalid_request_error(async_client: 'FlaskClient') -> None: - rv = async_client.post('/api', json={'id': 1, 'jsonrpc': '2.0'}) - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32600, - 'data': {'message': "Invalid JSON: {'id': 1, 'jsonrpc': '2.0'}"}, - 'message': 'Invalid Request', - 'name': 'InvalidRequestError', - }, - } - assert rv.status_code == 400 - - -def test_app_greeting_raise_invalid_params_error(async_client: 'FlaskClient') -> None: - rv = async_client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': 'Wrong'}) - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32602, - 'data': {'message': 'Parameter structures are by-position (list) or by-name (dict): Wrong'}, - 'message': 'Invalid params', - 'name': 'InvalidParamsError', - }, - } - assert rv.status_code == 400 - - rv = async_client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': [1]}) - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32602, - 'data': {'message': 'argument "name" (int) is not an instance of str'}, - 'message': 'Invalid params', - 'name': 'InvalidParamsError', - }, - } - assert rv.status_code == 400 - - rv = async_client.post( - '/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': {'name': 2}} - ) - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32602, - 'data': {'message': 'argument "name" (int) is not an instance of str'}, - 'message': 'Invalid params', - 'name': 'InvalidParamsError', - }, - } - assert rv.status_code == 400 - - -def test_app_greeting_raise_method_not_found_error(async_client: 'FlaskClient') -> None: - rv = async_client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'method-not-found'}) - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32601, - 'data': {'message': 'Method not found: method-not-found'}, - 'message': 'Method not found', - 'name': 'MethodNotFoundError', - }, - } - assert rv.status_code == 400 - - -def test_app_echo(async_client: 'FlaskClient') -> None: - rv = async_client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.echo', 'params': ['Python']}) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Python'} - assert rv.status_code == 200 - - rv = async_client.post( - '/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.echo', 'params': {'string': 'Flask'}} - ) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Flask'} - assert rv.status_code == 200 - - rv = async_client.post( - '/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.echo', 'params': {'string': None}} - ) - json_data = rv.get_json() - assert json_data['id'] == 1 - assert json_data['jsonrpc'] == '2.0' - assert json_data['error']['code'] == -32602 - assert "missing 1 required positional argument: 'string'" in json_data['error']['data']['message'] - assert json_data['error']['message'] == 'Invalid params' - assert json_data['error']['name'] == 'InvalidParamsError' - assert rv.status_code == 400 - - -def test_app_echo_raise_invalid_params_error(async_client: 'FlaskClient') -> None: - rv = async_client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.echo', 'params': 'Wrong'}) - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32602, - 'data': {'message': 'Parameter structures are by-position (list) or by-name (dict): Wrong'}, - 'message': 'Invalid params', - 'name': 'InvalidParamsError', - }, - } - assert rv.status_code == 400 - - rv = async_client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.echo', 'params': [1]}) - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32602, - 'data': {'message': 'argument "string" (int) is not an instance of str'}, - 'message': 'Invalid params', - 'name': 'InvalidParamsError', - }, - } - assert rv.status_code == 400 - - rv = async_client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.echo', 'params': {'name': 2}}) - json_data = rv.get_json() - assert json_data['id'] == 1 - assert json_data['jsonrpc'] == '2.0' - assert json_data['error']['code'] == -32602 - assert "missing 1 required positional argument: 'string'" in json_data['error']['data']['message'] - assert json_data['error']['message'] == 'Invalid params' - assert json_data['error']['name'] == 'InvalidParamsError' - assert rv.status_code == 400 - - rv = async_client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.echo'}) - json_data = rv.get_json() - assert json_data['id'] == 1 - assert json_data['jsonrpc'] == '2.0' - assert json_data['error']['code'] == -32602 - assert "missing 1 required positional argument: 'string'" in json_data['error']['data']['message'] - assert json_data['error']['message'] == 'Invalid params' - assert json_data['error']['name'] == 'InvalidParamsError' - assert rv.status_code == 400 - - -def test_app_notify(async_client: 'FlaskClient') -> None: - rv = async_client.post('/api', json={'jsonrpc': '2.0', 'method': 'jsonrpc.notify'}) - assert rv.json is None - assert rv.status_code == 204 - - rv = async_client.post('/api', json={'jsonrpc': '2.0', 'method': 'jsonrpc.notify', 'params': ['Some string']}) - assert rv.json is None - assert rv.status_code == 204 - - -def test_app_not_allow_notify(async_client: 'FlaskClient') -> None: - rv = async_client.post('/api', json={'jsonrpc': '2.0', 'method': 'jsonrpc.not_allow_notify'}) - assert rv.json == { - 'error': { - 'code': -32600, - 'data': { - 'message': "The method 'jsonrpc.not_allow_notify' doesn't allow Notification Request " - "object (without an 'id' member)" - }, - 'message': 'Invalid Request', - 'name': 'InvalidRequestError', - }, - 'id': None, - 'jsonrpc': '2.0', - } - assert rv.status_code == 400 - - rv = async_client.post( - '/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.not_allow_notify', 'params': ['Some string']} - ) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Not allow notify'} - assert rv.status_code == 200 - - -def test_app_no_return(async_client: 'FlaskClient') -> None: - rv = async_client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.noReturn'}) - assert rv.json == { - 'error': {'code': -32000, 'data': {'message': 'no return'}, 'message': 'Server error', 'name': 'ServerError'}, - 'id': 1, - 'jsonrpc': '2.0', - } - assert rv.status_code == 500 - - -def test_app_fails(async_client: 'FlaskClient') -> None: - rv = async_client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.fails', 'params': [2]}) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 2} - assert rv.status_code == 200 - - rv = async_client.post('/api', json={'id': '1', 'jsonrpc': '2.0', 'method': 'jsonrpc.fails', 'params': [1]}) - assert rv.json == { - 'id': '1', - 'jsonrpc': '2.0', - 'error': { - 'code': -32000, - 'data': {'message': 'number is odd'}, - 'message': 'Server error', - 'name': 'ServerError', - }, - } - assert rv.status_code == 500 - - -def test_app_strange_echo(async_client: 'FlaskClient') -> None: - data = { - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.strangeEcho', - 'params': ['string', {'a': 1}, ['a', 'b', 'c'], 23, 'Flask'], - } - rv = async_client.post('/api', json=data) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': ['string', {'a': 1}, ['a', 'b', 'c'], 23, 'Flask']} - assert rv.status_code == 200 - - data = { - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.strangeEcho', - 'params': ['string', {'a': 1}, ['a', 'b', 'c'], 23], - } - rv = async_client.post('/api', json=data) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': ['string', {'a': 1}, ['a', 'b', 'c'], 23, 'Default']} - assert rv.status_code == 200 - - -def test_app_sum(async_client: 'FlaskClient') -> None: - data = {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.sum', 'params': [1, 3]} - rv = async_client.post('/api', json=data) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 4} - assert rv.status_code == 200 - - data = {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.sum', 'params': [0.5, 1.5]} - rv = async_client.post('/api', json=data) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 2.0} - assert rv.status_code == 200 - - data = {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.sum', 'params': {'a': None, 'b': None}} - rv = async_client.post('/api', json=data) - json_data = rv.get_json() - assert json_data['id'] == 1 - assert json_data['jsonrpc'] == '2.0' - assert json_data['error']['code'] == -32602 - assert "missing 2 required positional arguments: 'a' and 'b'" in json_data['error']['data']['message'] - assert json_data['error']['message'] == 'Invalid params' - assert json_data['error']['name'] == 'InvalidParamsError' - assert rv.status_code == 400 - - -def test_app_decorators(async_client: 'FlaskClient') -> None: - data = {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.decorators', 'params': ['Python']} - rv = async_client.post('/api', json=data) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Python from decorator, ;)'} - assert rv.status_code == 200 - - data = {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.decorators', 'params': 'Python'} - rv = async_client.post('/api', json=data) - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32602, - 'data': {'message': 'Parameter structures are by-position (list) or by-name (dict): Python'}, - 'message': 'Invalid params', - 'name': 'InvalidParamsError', - }, - } - assert rv.status_code == 400 - - data = {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.decorators', 'params': [1]} - rv = async_client.post('/api', json=data) - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32602, - 'data': {'message': 'argument "string" (int) is not an instance of str'}, - 'message': 'Invalid params', - 'name': 'InvalidParamsError', - }, - } - assert rv.status_code == 400 - - -def test_app_decorators_wrapped(async_client: 'FlaskClient') -> None: - data = {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.decoratorsWrapped', 'params': ['Python']} - rv = async_client.post('/api', json=data) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Python from decorator, ;)'} - assert rv.status_code == 200 - - data = {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.decoratorsWrapped', 'params': 'Python'} - rv = async_client.post('/api', json=data) - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32602, - 'data': {'message': 'Parameter structures are by-position (list) or by-name (dict): Python'}, - 'message': 'Invalid params', - 'name': 'InvalidParamsError', - }, - } - assert rv.status_code == 400 - - # XXX: Typeguard does not instrument wrapped functions - data = {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.decoratorsWrapped', 'params': [1]} - rv = async_client.post('/api', json=data) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello 1 from decorator, ;)'} - assert rv.status_code == 200 - - -def test_app_return_status_code(async_client: 'FlaskClient') -> None: - rv = async_client.post( - '/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.returnStatusCode', 'params': ['OK']} - ) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Status Code OK'} - assert rv.status_code == 201 - - -def test_app_return_headers(async_client: 'FlaskClient') -> None: - rv = async_client.post( - '/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.returnHeaders', 'params': ['OK']} - ) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Headers OK'} - assert rv.status_code == 200 - assert ('X-JSONRPC', '1') in list(rv.headers) - - -def test_app_return_status_code_and_headers(async_client: 'FlaskClient') -> None: - rv = async_client.post( - '/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.returnStatusCodeAndHeaders', 'params': ['OK']} - ) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Status Code and Headers OK'} - assert rv.status_code == 400 - assert ('X-JSONRPC', '1') in list(rv.headers) - - -def test_app_with_rcp_batch(async_client: 'FlaskClient') -> None: - rv = async_client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting'}) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Flask JSON-RPC'} - assert rv.status_code == 200 - - rv = async_client.post( - '/api', - json=[ - {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['Python']}, - {'id': 2, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['Flask']}, - {'id': 3, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['JSON-RCP']}, - ], - ) - assert rv.json == [ - {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Python'}, - {'id': 2, 'jsonrpc': '2.0', 'result': 'Hello Flask'}, - {'id': 3, 'jsonrpc': '2.0', 'result': 'Hello JSON-RCP'}, - ] - assert rv.status_code == 200 - - rv = async_client.post( - '/api', - json=[ - {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['Python']}, - {'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['Flask']}, - {'id': 3, 'jsonrpc': '2.0', 'params': ['Flask']}, - {'id': 4, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['JSON-RCP']}, - ], - ) - assert rv.json == [ - {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Python'}, - { - 'id': 3, - 'jsonrpc': '2.0', - 'error': { - 'code': -32600, - 'data': {'message': "Invalid JSON: {'id': 3, 'jsonrpc': '2.0', 'params': ['Flask']}"}, - 'message': 'Invalid Request', - 'name': 'InvalidRequestError', - }, - }, - {'id': 4, 'jsonrpc': '2.0', 'result': 'Hello JSON-RCP'}, - ] - assert rv.status_code == 200 - - rv = async_client.post('/api', json={'id': 2, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting'}) - assert rv.json == {'id': 2, 'jsonrpc': '2.0', 'result': 'Hello Flask JSON-RPC'} - assert rv.status_code == 200 - - -def _test_app_class(async_client: 'FlaskClient') -> None: - rv = async_client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'classapp.index'}) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Flask JSON-RPC'} - assert rv.status_code == 200 - - rv = async_client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'greeting', 'params': ['Python']}) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Python'} - assert rv.status_code == 200 - - rv = async_client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'hello', 'params': {'name': 'Flask'}}) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Flask'} - assert rv.status_code == 200 - - rv = async_client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'echo', 'params': ['Python', 1]}) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Python'} - assert rv.status_code == 200 - - rv = async_client.post('/api', json={'jsonrpc': '2.0', 'method': 'notify', 'params': ['Python']}) - assert rv.status_code == 204 - - rv = async_client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'fails', 'params': [13]}) - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32000, - 'data': {'message': 'number is odd'}, - 'message': 'Server error', - 'name': 'ServerError', - }, - } - assert rv.status_code == 500 - - -def test_app_with_invalid_union(async_client: 'FlaskClient') -> None: - rv = async_client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.invalidUnion1', - 'params': {'color': {'name': 'Blue', 'tag': 'good'}}, - }, - ) - assert rv.status_code == 400 - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32602, - 'data': { - 'message': 'the only type of union that is supported is: typing.Union[T, ' 'None] or typing.Optional[T]' - }, - 'message': 'Invalid params', - 'name': 'InvalidParamsError', - }, - } - - rv = async_client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.invalidUnion2', - 'params': {'color': {'name': 'Blue', 'tag': 'good'}}, - }, - ) - assert rv.status_code == 400 - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32602, - 'data': { - 'message': 'the only type of union that is supported is: typing.Union[T, ' 'None] or typing.Optional[T]' - }, - 'message': 'Invalid params', - 'name': 'InvalidParamsError', - }, - } - - -def test_app_with_pythontypes(async_client: 'FlaskClient') -> None: - rv = async_client.post( - '/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.literalType', 'params': {'x': 'X'}} - ) - assert rv.status_code == 200 - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'X'} - - -def test_app_with_pythonclass(async_client: 'FlaskClient') -> None: - rv = async_client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createColor', - 'params': {'color': {'name': 'Blue', 'tag': 'good'}}, - }, - ) - assert rv.status_code == 200 - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': {'id': 1, 'name': 'Blue', 'tag': 'good'}} - - rv = async_client.post( - '/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.createColor', 'params': {'color': {'name': 'Red'}}} - ) - assert rv.status_code == 400 - data = rv.get_json() - assert data['id'] == 1 - assert data['jsonrpc'] == '2.0' - assert data['error']['code'] == -32602 - assert "missing 1 required positional argument: 'tag'" in data['error']['data']['message'] - assert data['error']['message'] == 'Invalid params' - assert data['error']['name'] == 'InvalidParamsError' - - rv = async_client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createManyColor', - 'params': {'colors': [{'name': 'Blue', 'tag': 'good'}, {'name': 'Red', 'tag': 'bad'}]}, - }, - ) - assert rv.status_code == 200 - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'result': [{'id': 0, 'name': 'Blue', 'tag': 'good'}, {'id': 1, 'name': 'Red', 'tag': 'bad'}], - } - - rv = async_client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createManyColor', - 'params': {'colors': [{'name': 'Blue', 'tag': 'good'}], 'color': {'name': 'Red', 'tag': 'bad'}}, - }, - ) - assert rv.status_code == 200 - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'result': [{'id': 0, 'name': 'Blue', 'tag': 'good'}, {'id': 1, 'name': 'Red', 'tag': 'bad'}], - } - - rv = async_client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createManyColor', - 'params': [ - [{'name': 'Blue', 'tag': 'good'}, {'name': 'Red', 'tag': 'bad'}], - {'name': 'Green', 'tag': 'yay'}, - ], - }, - ) - assert rv.status_code == 200 - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'result': [ - {'id': 0, 'name': 'Blue', 'tag': 'good'}, - {'id': 1, 'name': 'Red', 'tag': 'bad'}, - {'id': 2, 'name': 'Green', 'tag': 'yay'}, - ], - } - - rv = async_client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createManyFixColor', - 'params': {'colors': {'1': {'name': 'Blue', 'tag': 'good'}}}, - }, - ) - assert rv.status_code == 200 - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': [{'id': 1, 'name': 'Blue', 'tag': 'good'}]} - - rv = async_client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.removeColor', - 'params': {'color': {'id': 1, 'name': 'Blue', 'tag': 'good'}}, - }, - ) - assert rv.status_code == 200 - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': {'id': 1, 'name': 'Blue', 'tag': 'good'}} - - rv = async_client.post( - '/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removeColor', 'params': {'color': None}} - ) - assert rv.status_code == 200 - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': None} - - rv = async_client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removeColor', 'params': []}) - assert rv.status_code == 200 - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': None} - - rv = async_client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.removeColor', - 'params': {'color': {'id': 100, 'name': 'Blue', 'tag': 'good'}}, - }, - ) - assert rv.status_code == 500 - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32000, - 'data': {'color_id': 100, 'reason': 'The color with an ID greater than 10 does not exist.'}, - 'message': 'Server error', - 'name': 'ServerError', - }, - } - - -def test_app_with_dataclass(async_client: 'FlaskClient') -> None: - rv = async_client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createCar', - 'params': {'car': {'name': 'Fusca', 'tag': 'blue'}}, - }, - ) - assert rv.status_code == 200 - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': {'id': 1, 'name': 'Fusca', 'tag': 'blue'}} - - rv = async_client.post( - '/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.createCar', 'params': {'car': {'name': 'Fusca'}}} - ) - assert rv.status_code == 400 - data = rv.get_json() - assert data['id'] == 1 - assert data['jsonrpc'] == '2.0' - assert data['error']['code'] == -32602 - assert "missing 1 required positional argument: 'tag'" in data['error']['data']['message'] - assert data['error']['message'] == 'Invalid params' - assert data['error']['name'] == 'InvalidParamsError' - - rv = async_client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createManyCar', - 'params': {'cars': [{'name': 'Fusca', 'tag': 'blue'}, {'name': 'Kombi', 'tag': 'yellow'}]}, - }, - ) - assert rv.status_code == 200 - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'result': [{'id': 0, 'name': 'Fusca', 'tag': 'blue'}, {'id': 1, 'name': 'Kombi', 'tag': 'yellow'}], - } - - rv = async_client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createManyCar', - 'params': {'cars': [{'name': 'Fusca', 'tag': 'blue'}], 'car': {'name': 'Kombi', 'tag': 'yellow'}}, - }, - ) - assert rv.status_code == 200 - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'result': [{'id': 0, 'name': 'Fusca', 'tag': 'blue'}, {'id': 1, 'name': 'Kombi', 'tag': 'yellow'}], - } - - rv = async_client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createManyCar', - 'params': [ - [{'name': 'Fusca', 'tag': 'blue'}, {'name': 'Kombi', 'tag': 'yellow'}], - {'name': 'Gol', 'tag': 'white'}, - ], - }, - ) - assert rv.status_code == 200 - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'result': [ - {'id': 0, 'name': 'Fusca', 'tag': 'blue'}, - {'id': 1, 'name': 'Kombi', 'tag': 'yellow'}, - {'id': 2, 'name': 'Gol', 'tag': 'white'}, - ], - } - - rv = async_client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createManyFixCar', - 'params': {'cars': {'1': {'name': 'Fusca', 'tag': 'blue'}}}, - }, - ) - assert rv.status_code == 200 - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': [{'id': 1, 'name': 'Fusca', 'tag': 'blue'}]} - - rv = async_client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.removeCar', - 'params': {'car': {'id': 1, 'name': 'Fusca', 'tag': 'blue'}}, - }, - ) - assert rv.status_code == 200 - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': {'id': 1, 'name': 'Fusca', 'tag': 'blue'}} - - rv = async_client.post( - '/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removeCar', 'params': {'car': None}} - ) - assert rv.status_code == 200 - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': None} - - rv = async_client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removeCar', 'params': []}) - assert rv.status_code == 200 - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': None} - - rv = async_client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.removeCar', - 'params': {'car': {'id': 100, 'name': 'Fusca', 'tag': 'blue'}}, - }, - ) - assert rv.status_code == 500 - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32000, - 'data': {'car_id': 100, 'reason': 'The car with an ID greater than 10 does not exist.'}, - 'message': 'Server error', - 'name': 'ServerError', - }, - } - - -def test_app_with_pydantic(async_client: 'FlaskClient') -> None: - rv = async_client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createPet', - 'params': {'pet': {'name': 'Eve', 'tag': 'dog'}}, - }, - ) - assert rv.status_code == 200 - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': {'id': 1, 'name': 'Eve', 'tag': 'dog'}} - - rv = async_client.post( - '/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.createPet', 'params': {'pet': {'name': 'Eve'}}} - ) - assert rv.status_code == 400 - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32602, - 'data': { - 'message': '1 validation error for NewPet\n' - 'tag\n' - " Field required [type=missing, input_value={'name': 'Eve'}, " - 'input_type=dict]\n' - ' For further information visit ' - 'https://errors.pydantic.dev/2.9/v/missing' - }, - 'message': 'Invalid params', - 'name': 'InvalidParamsError', - }, - } - - rv = async_client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createManyPet', - 'params': {'pets': [{'name': 'Eve', 'tag': 'dog'}, {'name': 'Lou', 'tag': 'dog'}]}, - }, - ) - assert rv.status_code == 200 - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'result': [{'id': 0, 'name': 'Eve', 'tag': 'dog'}, {'id': 1, 'name': 'Lou', 'tag': 'dog'}], - } - - rv = async_client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createManyPet', - 'params': {'pets': [{'name': 'Eve', 'tag': 'dog'}], 'pet': {'name': 'Lou', 'tag': 'dog'}}, - }, - ) - assert rv.status_code == 200 - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'result': [{'id': 0, 'name': 'Eve', 'tag': 'dog'}, {'id': 1, 'name': 'Lou', 'tag': 'dog'}], - } - - rv = async_client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createManyPet', - 'params': [ - [{'name': 'Eve', 'tag': 'dog'}, {'name': 'Lou', 'tag': 'dog'}], - {'name': 'Tequila', 'tag': 'cat'}, - ], - }, - ) - assert rv.status_code == 200 - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'result': [ - {'id': 0, 'name': 'Eve', 'tag': 'dog'}, - {'id': 1, 'name': 'Lou', 'tag': 'dog'}, - {'id': 2, 'name': 'Tequila', 'tag': 'cat'}, - ], - } - - rv = async_client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createManyFixPet', - 'params': {'pets': {'1': {'name': 'Eve', 'tag': 'dog'}}}, - }, - ) - assert rv.status_code == 200 - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': [{'id': 1, 'name': 'Eve', 'tag': 'dog'}]} - - rv = async_client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.removePet', - 'params': {'pet': {'id': 1, 'name': 'Eve', 'tag': 'dog'}}, - }, - ) - assert rv.status_code == 200 - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': {'id': 1, 'name': 'Eve', 'tag': 'dog'}} - - rv = async_client.post( - '/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removePet', 'params': {'pet': None}} - ) - assert rv.status_code == 200 - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': None} - - rv = async_client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removePet', 'params': []}) - assert rv.status_code == 200 - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': None} - - rv = async_client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.removePet', - 'params': {'pet': {'id': 100, 'name': 'Lou', 'tag': 'dog'}}, - }, - ) - assert rv.status_code == 500 - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32000, - 'data': {'pet_id': 100, 'reason': 'The pet with an ID greater than 10 does not exist.'}, - 'message': 'Server error', - 'name': 'ServerError', - }, - } - - -def test_app_system_describe(async_client: 'FlaskClient') -> None: - rv = async_client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'rpc.describe'}) - data = rv.get_json() - assert data['id'] == 1 - assert data['jsonrpc'] == '2.0' - assert data['result']['name'] == 'Flask-JSONRPC' - assert data['result']['version'] == '2.0' - assert data['result']['servers'] is not None - assert 'url' in data['result']['servers'][0] - assert data['result']['methods'] == { - 'jsonrpc.greeting': { - 'type': 'method', - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'name', 'type': 'String'}], - 'returns': {'type': 'String'}, - }, - 'jsonrpc.echo': { - 'type': 'method', - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'string', 'type': 'String'}, {'name': '_some', 'type': 'Object'}], - 'returns': {'type': 'String'}, - }, - 'jsonrpc.notify': { - 'type': 'method', - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': '_string', 'type': 'String'}], - 'returns': {'type': 'Null'}, - }, - 'jsonrpc.not_allow_notify': { - 'type': 'method', - 'options': {'notification': False, 'validate': True}, - 'params': [{'name': '_string', 'type': 'String'}], - 'returns': {'type': 'String'}, - }, - 'jsonrpc.fails': { - 'type': 'method', - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'n', 'type': 'Number'}], - 'returns': {'type': 'Number'}, - }, - 'jsonrpc.strangeEcho': { - 'type': 'method', - 'options': {'notification': True, 'validate': True}, - 'params': [ - {'name': 'string', 'type': 'String'}, - {'name': 'omg', 'type': 'Object'}, - {'name': 'wtf', 'type': 'Array'}, - {'name': 'nowai', 'type': 'Number'}, - {'name': 'yeswai', 'type': 'String'}, - ], - 'returns': {'type': 'Array'}, - }, - 'jsonrpc.sum': { - 'type': 'method', - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'a', 'type': 'Number'}, {'name': 'b', 'type': 'Number'}], - 'returns': {'type': 'Number'}, - }, - 'jsonrpc.createCar': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'car', 'type': 'Object'}], - 'returns': {'type': 'Object'}, - 'type': 'method', - }, - 'jsonrpc.createColor': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'color', 'type': 'Object'}], - 'returns': {'type': 'Object'}, - 'type': 'method', - }, - 'jsonrpc.createManyCar': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'cars', 'type': 'Array'}, {'name': 'car', 'type': 'Object'}], - 'returns': {'type': 'Array'}, - 'type': 'method', - }, - 'jsonrpc.createManyColor': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'colors', 'type': 'Array'}, {'name': 'color', 'type': 'Object'}], - 'returns': {'type': 'Array'}, - 'type': 'method', - }, - 'jsonrpc.createManyFixCar': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'cars', 'type': 'Object'}], - 'returns': {'type': 'Array'}, - 'type': 'method', - }, - 'jsonrpc.createManyFixColor': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'colors', 'type': 'Object'}], - 'returns': {'type': 'Array'}, - 'type': 'method', - }, - 'jsonrpc.createManyFixPet': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'pets', 'type': 'Object'}], - 'returns': {'type': 'Array'}, - 'type': 'method', - }, - 'jsonrpc.createManyPet': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'pets', 'type': 'Array'}, {'name': 'pet', 'type': 'Object'}], - 'returns': {'type': 'Array'}, - 'type': 'method', - }, - 'jsonrpc.createPet': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'pet', 'type': 'Object'}], - 'returns': {'type': 'Object'}, - 'type': 'method', - }, - 'jsonrpc.decorators': { - 'type': 'method', - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'string', 'type': 'String'}], - 'returns': {'type': 'String'}, - }, - 'jsonrpc.decoratorsWrapped': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'string', 'type': 'String'}], - 'returns': {'type': 'String'}, - 'type': 'method', - }, - 'jsonrpc.returnStatusCode': { - 'type': 'method', - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 's', 'type': 'String'}], - 'returns': {'type': 'Array'}, - }, - 'jsonrpc.removeCar': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'car', 'type': 'Object'}], - 'returns': {'type': 'Object'}, - 'type': 'method', - }, - 'jsonrpc.removeColor': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'color', 'type': 'Object'}], - 'returns': {'type': 'Object'}, - 'type': 'method', - }, - 'jsonrpc.removePet': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'pet', 'type': 'Object'}], - 'returns': {'type': 'Object'}, - 'type': 'method', - }, - 'jsonrpc.returnHeaders': { - 'type': 'method', - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 's', 'type': 'String'}], - 'returns': {'type': 'Array'}, - }, - 'jsonrpc.returnStatusCodeAndHeaders': { - 'type': 'method', - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 's', 'type': 'String'}], - 'returns': {'type': 'Array'}, - }, - 'jsonrpc.not_validate': { - 'type': 'method', - 'options': {'notification': True, 'validate': False}, - 'params': [{'name': 's', 'type': 'Object'}], - 'returns': {'type': 'Object'}, - }, - 'jsonrpc.invalidUnion1': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'color', 'type': 'Object'}], - 'returns': {'type': 'Object'}, - 'type': 'method', - }, - 'jsonrpc.invalidUnion2': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'color', 'type': 'Object'}], - 'returns': {'type': 'Object'}, - 'type': 'method', - }, - 'jsonrpc.literalType': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'x', 'type': 'Object'}], - 'returns': {'type': 'Object'}, - 'type': 'method', - }, - 'jsonrpc.mixin_not_validate': { - 'type': 'method', - 'options': {'notification': True, 'validate': False}, - 'params': [ - {'name': 's', 'type': 'Object'}, - {'name': 't', 'type': 'Number'}, - {'name': 'u', 'type': 'Object'}, - {'name': 'v', 'type': 'String'}, - {'name': 'x', 'type': 'Object'}, - {'name': 'z', 'type': 'Object'}, - ], - 'returns': {'type': 'Object'}, - }, - 'jsonrpc.noReturn': { - 'type': 'method', - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': '_string', 'type': 'String'}], - 'returns': {'type': 'Null'}, - }, - 'classapp.index': { - 'type': 'method', - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'name', 'type': 'String'}], - 'returns': {'type': 'String'}, - }, - 'greeting': { - 'type': 'method', - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'name', 'type': 'String'}], - 'returns': {'type': 'String'}, - }, - 'hello': { - 'type': 'method', - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'name', 'type': 'String'}], - 'returns': {'type': 'String'}, - }, - 'echo': { - 'type': 'method', - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'string', 'type': 'String'}, {'name': '_some', 'type': 'Object'}], - 'returns': {'type': 'String'}, - }, - 'notify': { - 'type': 'method', - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': '_string', 'type': 'String'}], - 'returns': {'type': 'Null'}, - }, - 'not_allow_notify': { - 'type': 'method', - 'options': {'notification': False, 'validate': True}, - 'params': [{'name': '_string', 'type': 'String'}], - 'returns': {'type': 'String'}, - }, - 'fails': { - 'type': 'method', - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'n', 'type': 'Number'}], - 'returns': {'type': 'Number'}, - }, - 'rpc.describe': {'options': {}, 'params': [], 'returns': {'type': 'Object'}, 'type': 'method'}, - } - - assert rv.status_code == 200 diff --git a/tests/unit/test_blueprints.py b/tests/unit/test_blueprints.py new file mode 100644 index 00000000..462ef4ff --- /dev/null +++ b/tests/unit/test_blueprints.py @@ -0,0 +1,215 @@ +# Copyright (c) 2020-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the Cenobit Technologies nor the names of +# its contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +import typing as t + +from flask import Flask + +import pytest + +from flask_jsonrpc import JSONRPC, JSONRPCBlueprint + +# Python 3.10+ +try: + from typing import Self +except ImportError: # pragma: no cover + from typing_extensions import Self + + +class CustomException(Exception): + def __init__(self: Self, message: str, data: dict[str, t.Any]) -> None: + super().__init__(message) + self.message = message + self.data = data + + +def test_jsonrpc_blueprint() -> None: + jsonrpc_api_1 = JSONRPCBlueprint('jsonrpc_api_1', __name__) + + @jsonrpc_api_1.method('blue1.fn2') + def fn1_b1(s: str) -> str: + return f'b1: Foo {s}' + + def fn2_b1(s: str) -> str: + return f'b2: Foo {s}' + + jsonrpc_api_1.register(fn1_b1, name='blue1.fn1') + + jsonrpc_api_2 = JSONRPCBlueprint('jsonrpc_api_2', __name__) + + @jsonrpc_api_2.method('blue2.fn2') + def fn1_b2(s: str) -> str: + return f'b2: Foo {s}' + + @jsonrpc_api_2.method('blue2.fn1') + def fn2_b2(s: str) -> str: + return f'b2: Bar {s}' + + jsonrpc_api_3 = JSONRPCBlueprint('jsonrpc_api_3', __name__) + + @jsonrpc_api_3.method('blue3.fn2') + def fn1_b3(s: str) -> str: + return f'b3: Foo {s}' + + app = Flask('test_app', instance_relative_config=True) + jsonrpc = JSONRPC(app, '/api', enable_web_browsable_api=True) + jsonrpc.register_blueprint(app, jsonrpc_api_1, url_prefix='/b1') + jsonrpc.register_blueprint(app, jsonrpc_api_2, url_prefix='/b2') + jsonrpc.register_blueprint(app, jsonrpc_api_3, url_prefix='/b3') + + with app.test_client() as client: + rv = client.post('/api/b1', json={'id': 1, 'jsonrpc': '2.0', 'method': 'blue1.fn2', 'params': [':)']}) + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'b1: Foo :)'} + assert rv.status_code == 200 + + rv = client.post('/api/b1', json={'id': 1, 'jsonrpc': '2.0', 'method': 'blue1.fn1', 'params': [':)']}) + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'b1: Foo :)'} + assert rv.status_code == 200 + + rv = client.post('/api/b2', json={'id': 1, 'jsonrpc': '2.0', 'method': 'blue2.fn2', 'params': [':)']}) + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'b2: Foo :)'} + assert rv.status_code == 200 + + rv = client.post('/api/b2', json={'id': 1, 'jsonrpc': '2.0', 'method': 'blue2.fn1', 'params': [':)']}) + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'b2: Bar :)'} + assert rv.status_code == 200 + + rv = client.post('/api/b3', json={'id': 1, 'jsonrpc': '2.0', 'method': 'blue3.fn2', 'params': [':)']}) + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'b3: Foo :)'} + assert rv.status_code == 200 + + +def test_jsonrpc_blueprint_using_error_handler() -> None: + jsonrpc_api_1 = JSONRPCBlueprint('jsonrpc_api_1', __name__) + + @jsonrpc_api_1.errorhandler(CustomException) + def handle_custom_exc_jsonrpc_api_1(exc: CustomException) -> str: + return f"jsonrpc_api_1: {exc.data['message']}" + + @jsonrpc_api_1.method('blue1.index') + def index_b1() -> str: + return 'b1 index' + + @jsonrpc_api_1.method('blue1.errorhandler') + def error_b1() -> t.NoReturn: + raise CustomException('Testing error handler', data={'message': 'Flask JSON-RPC', 'code': '0000'}) + + jsonrpc_api_2 = JSONRPCBlueprint('jsonrpc_api_2', __name__) + + @jsonrpc_api_2.errorhandler(CustomException) + def handle_custom_exc_jsonrpc_api_2(exc: CustomException) -> str: + return f"jsonrpc_api_2: {exc.data['message']}" + + @jsonrpc_api_2.method('blue2.index') + def index_b2() -> str: + return 'b2 index' + + @jsonrpc_api_2.method('blue2.errorhandler') + def error_b2() -> t.NoReturn: + raise CustomException('Testing error handler', data={'message': 'Flask JSON-RPC', 'code': '0000'}) + + app = Flask('test_app', instance_relative_config=True) + jsonrpc = JSONRPC(app, '/api', enable_web_browsable_api=True) + jsonrpc.register_blueprint(app, jsonrpc_api_1, url_prefix='/b1') + jsonrpc.register_blueprint(app, jsonrpc_api_2, url_prefix='/b2') + + with app.test_client() as client: + rv = client.post('/api/b1', json={'id': 1, 'jsonrpc': '2.0', 'method': 'blue1.index', 'params': []}) + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'b1 index'} + assert rv.status_code == 200 + + rv = client.post('/api/b1', json={'id': 1, 'jsonrpc': '2.0', 'method': 'blue1.errorhandler', 'params': []}) + assert rv.json == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32000, + 'data': 'jsonrpc_api_1: Flask JSON-RPC', + 'message': 'Server error', + 'name': 'ServerError', + }, + } + assert rv.status_code == 500 + + rv = client.post('/api/b2', json={'id': 1, 'jsonrpc': '2.0', 'method': 'blue2.index', 'params': []}) + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'b2 index'} + assert rv.status_code == 200 + + rv = client.post('/api/b2', json={'id': 1, 'jsonrpc': '2.0', 'method': 'blue2.errorhandler', 'params': []}) + assert rv.json == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32000, + 'data': 'jsonrpc_api_2: Flask JSON-RPC', + 'message': 'Server error', + 'name': 'ServerError', + }, + } + assert rv.status_code == 500 + + +def test_jsonrpc_blueprint_with_server_name() -> None: + jsonrpc_api_1 = JSONRPCBlueprint('jsonrpc_api_1', __name__) + + @jsonrpc_api_1.method('blue1.fn2') + def fn1_b1(s: str) -> str: + return f'b1: Foo {s}' + + app = Flask('test_app', instance_relative_config=True) + app.config.update({'SERVER_NAME': 'domain:80'}) + jsonrpc = JSONRPC(app, '/api', enable_web_browsable_api=True) + jsonrpc.register_blueprint(app, jsonrpc_api_1, url_prefix='/b1') + + @jsonrpc.method('app.index') + def index() -> str: + return 'Welcome to Flask JSON-RPC' + + with app.test_client() as client: + rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'app.index', 'params': []}) + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Welcome to Flask JSON-RPC'} + assert rv.status_code == 200 + + rv = client.post('/api/b1', json={'id': 1, 'jsonrpc': '2.0', 'method': 'blue1.fn2', 'params': [':)']}) + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'b1: Foo :)'} + assert rv.status_code == 200 + + +def test_jsonrpc_blueprint_with_method_without_annotation() -> None: + jsonrpc_api_1 = JSONRPCBlueprint('jsonrpc_api_1', __name__) + + with pytest.raises(ValueError, match='no type annotations present to: blue1.fn2'): + + @jsonrpc_api_1.method('blue1.fn2') + def fn1_b1(s): # noqa: ANN001, ANN202 + return f'b1: Foo {s}' + + app = Flask('test_app', instance_relative_config=True) + jsonrpc = JSONRPC(app, '/api', enable_web_browsable_api=True) + + @jsonrpc.method('app.index') + def index() -> str: + return 'Welcome to Flask JSON-RPC' diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py deleted file mode 100644 index 201af5ff..00000000 --- a/tests/unit/test_client.py +++ /dev/null @@ -1,1281 +0,0 @@ -# Copyright (c) 2020-2024, Cenobit Technologies, Inc. http://cenobit.es/ -# All rights reserved. -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: -# -# * Redistributions of source code must retain the above copyright notice, -# this list of conditions and the following disclaimer. -# * Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# * Neither the name of the Cenobit Technologies nor the names of -# its contributors may be used to endorse or promote products derived from -# this software without specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE -# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. -import json -import typing as t - -if t.TYPE_CHECKING: - from flask.testing import FlaskClient - - -def test_app_greeting(client: 'FlaskClient') -> None: - rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting'}) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Flask JSON-RPC'} - assert rv.status_code == 200 - - rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['Python']}) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Python'} - assert rv.status_code == 200 - - rv = client.post( - '/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': {'name': 'Flask'}} - ) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Flask'} - assert rv.status_code == 200 - - rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': {'name': 1}}) - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32602, - 'data': {'message': 'argument "name" (int) is not an instance of str'}, - 'message': 'Invalid params', - 'name': 'InvalidParamsError', - }, - } - assert rv.status_code == 400 - - -def test_app_greeting_with_different_content_types(client: 'FlaskClient') -> None: - rv = client.post( - '/api', - data=json.dumps({'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting'}), - headers={'Content-Type': 'application/json-rpc'}, - ) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Flask JSON-RPC'} - assert rv.status_code == 200 - - rv = client.post( - '/api', - data=json.dumps({'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['Python']}), - headers={'Content-Type': 'application/jsonrequest'}, - ) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Python'} - assert rv.status_code == 200 - - rv = client.post( - '/api', - data=json.dumps({'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': {'name': 'Flask'}}), - headers={'Content-Type': 'application/json'}, - ) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Flask'} - assert rv.status_code == 200 - - -def test_app_greeting_raise_parse_error(client: 'FlaskClient') -> None: - rv = client.post('/api', data={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting'}) - assert rv.json == { - 'id': None, - 'jsonrpc': '2.0', - 'error': { - 'code': -32700, - 'data': { - 'message': 'Invalid mime type for JSON: application/x-www-form-urlencoded, ' - 'use header Content-Type: application/json' - }, - 'message': 'Parse error', - 'name': 'ParseError', - }, - } - assert rv.status_code == 400 - - rv = client.post( - '/api', - data="{'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting'}", - headers={'Content-Type': 'application/json'}, - ) - assert rv.json == { - 'id': None, - 'jsonrpc': '2.0', - 'error': { - 'code': -32700, - 'data': {'message': "Invalid JSON: b\"{'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting'}\""}, - 'message': 'Parse error', - 'name': 'ParseError', - }, - } - assert rv.status_code == 400 - - rv = client.post( - '/api', - data="""[ - {'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['Flask'], 'id': '1'}, - {'jsonrpc': '2.0', 'method' - ]""", - headers={'Content-Type': 'application/json'}, - ) - assert rv.json == { - 'id': None, - 'jsonrpc': '2.0', - 'error': { - 'code': -32700, - 'data': { - 'message': "Invalid JSON: b\"[\\n {'jsonrpc': " - "'2.0', 'method': 'jsonrpc.greeting', 'params': " - "['Flask'], 'id': '1'},\\n " - "{'jsonrpc': '2.0', 'method'\\n " - ']"' - }, - 'message': 'Parse error', - 'name': 'ParseError', - }, - } - assert rv.status_code == 400 - - -def test_app_greeting_raise_invalid_request_error(client: 'FlaskClient') -> None: - rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0'}) - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32600, - 'data': {'message': "Invalid JSON: {'id': 1, 'jsonrpc': '2.0'}"}, - 'message': 'Invalid Request', - 'name': 'InvalidRequestError', - }, - } - assert rv.status_code == 400 - - -def test_app_greeting_raise_invalid_params_error(client: 'FlaskClient') -> None: - rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': 'Wrong'}) - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32602, - 'data': {'message': 'Parameter structures are by-position (list) or by-name (dict): Wrong'}, - 'message': 'Invalid params', - 'name': 'InvalidParamsError', - }, - } - assert rv.status_code == 400 - - rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': [1]}) - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32602, - 'data': {'message': 'argument "name" (int) is not an instance of str'}, - 'message': 'Invalid params', - 'name': 'InvalidParamsError', - }, - } - assert rv.status_code == 400 - - rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': {'name': 2}}) - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32602, - 'data': {'message': 'argument "name" (int) is not an instance of str'}, - 'message': 'Invalid params', - 'name': 'InvalidParamsError', - }, - } - assert rv.status_code == 400 - - -def test_app_greeting_raise_method_not_found_error(client: 'FlaskClient') -> None: - rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'method-not-found'}) - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32601, - 'data': {'message': 'Method not found: method-not-found'}, - 'message': 'Method not found', - 'name': 'MethodNotFoundError', - }, - } - assert rv.status_code == 400 - - -def test_app_echo(client: 'FlaskClient') -> None: - rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.echo', 'params': ['Python']}) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Python'} - assert rv.status_code == 200 - - rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.echo', 'params': {'string': 'Flask'}}) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Flask'} - assert rv.status_code == 200 - - rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.echo', 'params': {'string': None}}) - json_data = rv.get_json() - assert json_data['id'] == 1 - assert json_data['jsonrpc'] == '2.0' - assert json_data['error']['code'] == -32602 - assert "missing 1 required positional argument: 'string'" in json_data['error']['data']['message'] - assert json_data['error']['message'] == 'Invalid params' - assert json_data['error']['name'] == 'InvalidParamsError' - assert rv.status_code == 400 - - -def test_app_echo_raise_invalid_params_error(client: 'FlaskClient') -> None: - rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.echo', 'params': 'Wrong'}) - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32602, - 'data': {'message': 'Parameter structures are by-position (list) or by-name (dict): Wrong'}, - 'message': 'Invalid params', - 'name': 'InvalidParamsError', - }, - } - assert rv.status_code == 400 - - rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.echo', 'params': [1]}) - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32602, - 'data': {'message': 'argument "string" (int) is not an instance of str'}, - 'message': 'Invalid params', - 'name': 'InvalidParamsError', - }, - } - assert rv.status_code == 400 - - rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.echo', 'params': {'name': 2}}) - json_data = rv.get_json() - assert json_data['id'] == 1 - assert json_data['jsonrpc'] == '2.0' - assert json_data['error']['code'] == -32602 - assert "missing 1 required positional argument: 'string'" in json_data['error']['data']['message'] - assert json_data['error']['message'] == 'Invalid params' - assert json_data['error']['name'] == 'InvalidParamsError' - assert rv.status_code == 400 - - rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.echo'}) - json_data = rv.get_json() - assert json_data['id'] == 1 - assert json_data['jsonrpc'] == '2.0' - assert json_data['error']['code'] == -32602 - assert "missing 1 required positional argument: 'string'" in json_data['error']['data']['message'] - assert json_data['error']['message'] == 'Invalid params' - assert json_data['error']['name'] == 'InvalidParamsError' - assert rv.status_code == 400 - - -def test_app_notify(client: 'FlaskClient') -> None: - rv = client.post('/api', json={'jsonrpc': '2.0', 'method': 'jsonrpc.notify'}) - assert rv.json is None - assert rv.status_code == 204 - - rv = client.post('/api', json={'jsonrpc': '2.0', 'method': 'jsonrpc.notify', 'params': ['Some string']}) - assert rv.json is None - assert rv.status_code == 204 - - -def test_app_not_allow_notify(client: 'FlaskClient') -> None: - rv = client.post('/api', json={'jsonrpc': '2.0', 'method': 'jsonrpc.not_allow_notify'}) - assert rv.json == { - 'error': { - 'code': -32600, - 'data': { - 'message': "The method 'jsonrpc.not_allow_notify' doesn't allow Notification Request " - "object (without an 'id' member)" - }, - 'message': 'Invalid Request', - 'name': 'InvalidRequestError', - }, - 'id': None, - 'jsonrpc': '2.0', - } - assert rv.status_code == 400 - - rv = client.post( - '/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.not_allow_notify', 'params': ['Some string']} - ) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Not allow notify'} - assert rv.status_code == 200 - - -def test_app_no_return(client: 'FlaskClient') -> None: - rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.noReturn'}) - assert rv.json == { - 'error': {'code': -32000, 'data': {'message': 'no return'}, 'message': 'Server error', 'name': 'ServerError'}, - 'id': 1, - 'jsonrpc': '2.0', - } - assert rv.status_code == 500 - - -def test_app_fails(client: 'FlaskClient') -> None: - rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.fails', 'params': [2]}) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 2} - assert rv.status_code == 200 - - rv = client.post('/api', json={'id': '1', 'jsonrpc': '2.0', 'method': 'jsonrpc.fails', 'params': [1]}) - assert rv.json == { - 'id': '1', - 'jsonrpc': '2.0', - 'error': { - 'code': -32000, - 'data': {'message': 'number is odd'}, - 'message': 'Server error', - 'name': 'ServerError', - }, - } - assert rv.status_code == 500 - - -def test_app_strange_echo(client: 'FlaskClient') -> None: - data = { - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.strangeEcho', - 'params': ['string', {'a': 1}, ['a', 'b', 'c'], 23, 'Flask'], - } - rv = client.post('/api', json=data) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': ['string', {'a': 1}, ['a', 'b', 'c'], 23, 'Flask']} - assert rv.status_code == 200 - - data = { - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.strangeEcho', - 'params': ['string', {'a': 1}, ['a', 'b', 'c'], 23], - } - rv = client.post('/api', json=data) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': ['string', {'a': 1}, ['a', 'b', 'c'], 23, 'Default']} - assert rv.status_code == 200 - - -def test_app_sum(client: 'FlaskClient') -> None: - data = {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.sum', 'params': [1, 3]} - rv = client.post('/api', json=data) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 4} - assert rv.status_code == 200 - - data = {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.sum', 'params': [0.5, 1.5]} - rv = client.post('/api', json=data) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 2.0} - assert rv.status_code == 200 - - data = {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.sum', 'params': {'a': None, 'b': None}} - rv = client.post('/api', json=data) - json_data = rv.get_json() - assert json_data['id'] == 1 - assert json_data['jsonrpc'] == '2.0' - assert json_data['error']['code'] == -32602 - assert "missing 2 required positional arguments: 'a' and 'b'" in json_data['error']['data']['message'] - assert json_data['error']['message'] == 'Invalid params' - assert json_data['error']['name'] == 'InvalidParamsError' - assert rv.status_code == 400 - - -def test_app_decorators(client: 'FlaskClient') -> None: - data = {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.decorators', 'params': ['Python']} - rv = client.post('/api', json=data) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Python from decorator, ;)'} - assert rv.status_code == 200 - - data = {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.decorators', 'params': 'Python'} - rv = client.post('/api', json=data) - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32602, - 'data': {'message': 'Parameter structures are by-position (list) or by-name (dict): Python'}, - 'message': 'Invalid params', - 'name': 'InvalidParamsError', - }, - } - assert rv.status_code == 400 - - data = {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.decorators', 'params': [1]} - rv = client.post('/api', json=data) - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32602, - 'data': {'message': 'argument "string" (int) is not an instance of str'}, - 'message': 'Invalid params', - 'name': 'InvalidParamsError', - }, - } - assert rv.status_code == 400 - - -def test_app_decorators_wrapped(client: 'FlaskClient') -> None: - data = {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.decoratorsWrapped', 'params': ['Python']} - rv = client.post('/api', json=data) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Python from decorator, ;)'} - assert rv.status_code == 200 - - data = {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.decoratorsWrapped', 'params': 'Python'} - rv = client.post('/api', json=data) - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32602, - 'data': {'message': 'Parameter structures are by-position (list) or by-name (dict): Python'}, - 'message': 'Invalid params', - 'name': 'InvalidParamsError', - }, - } - assert rv.status_code == 400 - - # XXX: Typeguard does not instrument wrapped functions - data = {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.decoratorsWrapped', 'params': [1]} - rv = client.post('/api', json=data) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello 1 from decorator, ;)'} - assert rv.status_code == 200 - - -def test_app_return_status_code(client: 'FlaskClient') -> None: - rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.returnStatusCode', 'params': ['OK']}) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Status Code OK'} - assert rv.status_code == 201 - - -def test_app_return_headers(client: 'FlaskClient') -> None: - rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.returnHeaders', 'params': ['OK']}) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Headers OK'} - assert rv.status_code == 200 - assert ('X-JSONRPC', '1') in list(rv.headers) - - -def test_app_return_status_code_and_headers(client: 'FlaskClient') -> None: - rv = client.post( - '/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.returnStatusCodeAndHeaders', 'params': ['OK']} - ) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Status Code and Headers OK'} - assert rv.status_code == 400 - assert ('X-JSONRPC', '1') in list(rv.headers) - - -def test_app_with_rcp_batch(client: 'FlaskClient') -> None: - rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting'}) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Flask JSON-RPC'} - assert rv.status_code == 200 - - rv = client.post( - '/api', - json=[ - {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['Python']}, - {'id': 2, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['Flask']}, - {'id': 3, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['JSON-RCP']}, - ], - ) - assert rv.json == [ - {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Python'}, - {'id': 2, 'jsonrpc': '2.0', 'result': 'Hello Flask'}, - {'id': 3, 'jsonrpc': '2.0', 'result': 'Hello JSON-RCP'}, - ] - assert rv.status_code == 200 - - rv = client.post( - '/api', - json=[ - {'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['Python']}, - {'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['Flask']}, - {'id': 3, 'jsonrpc': '2.0', 'params': ['Flask']}, - {'id': 4, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting', 'params': ['JSON-RCP']}, - ], - ) - assert rv.json == [ - {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Python'}, - { - 'id': 3, - 'jsonrpc': '2.0', - 'error': { - 'code': -32600, - 'data': {'message': "Invalid JSON: {'id': 3, 'jsonrpc': '2.0', 'params': ['Flask']}"}, - 'message': 'Invalid Request', - 'name': 'InvalidRequestError', - }, - }, - {'id': 4, 'jsonrpc': '2.0', 'result': 'Hello JSON-RCP'}, - ] - assert rv.status_code == 200 - - rv = client.post('/api', json={'id': 2, 'jsonrpc': '2.0', 'method': 'jsonrpc.greeting'}) - assert rv.json == {'id': 2, 'jsonrpc': '2.0', 'result': 'Hello Flask JSON-RPC'} - assert rv.status_code == 200 - - -def test_app_class(client: 'FlaskClient') -> None: - rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'classapp.index'}) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Flask JSON-RPC'} - assert rv.status_code == 200 - - rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'greeting', 'params': ['Python']}) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Python'} - assert rv.status_code == 200 - - rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'hello', 'params': {'name': 'Flask'}}) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello Flask'} - assert rv.status_code == 200 - - rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'echo', 'params': ['Python', 1]}) - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Python'} - assert rv.status_code == 200 - - rv = client.post('/api', json={'jsonrpc': '2.0', 'method': 'notify', 'params': ['Python']}) - assert rv.status_code == 204 - - rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'fails', 'params': [13]}) - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32000, - 'data': {'message': 'number is odd'}, - 'message': 'Server error', - 'name': 'ServerError', - }, - } - assert rv.status_code == 500 - - -def test_app_with_invalid_union(client: 'FlaskClient') -> None: - rv = client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.invalidUnion1', - 'params': {'color': {'name': 'Blue', 'tag': 'good'}}, - }, - ) - assert rv.status_code == 400 - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32602, - 'data': { - 'message': 'the only type of union that is supported is: typing.Union[T, ' 'None] or typing.Optional[T]' - }, - 'message': 'Invalid params', - 'name': 'InvalidParamsError', - }, - } - - rv = client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.invalidUnion2', - 'params': {'color': {'name': 'Blue', 'tag': 'good'}}, - }, - ) - assert rv.status_code == 400 - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32602, - 'data': { - 'message': 'the only type of union that is supported is: typing.Union[T, ' 'None] or typing.Optional[T]' - }, - 'message': 'Invalid params', - 'name': 'InvalidParamsError', - }, - } - - -def test_app_with_pythontypes(client: 'FlaskClient') -> None: - rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.literalType', 'params': {'x': 'X'}}) - assert rv.status_code == 200 - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'X'} - - -def test_app_with_pythonclass(client: 'FlaskClient') -> None: - rv = client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createColor', - 'params': {'color': {'name': 'Blue', 'tag': 'good'}}, - }, - ) - assert rv.status_code == 200 - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': {'id': 1, 'name': 'Blue', 'tag': 'good'}} - - rv = client.post( - '/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.createColor', 'params': {'color': {'name': 'Red'}}} - ) - assert rv.status_code == 400 - data = rv.get_json() - assert data['id'] == 1 - assert data['jsonrpc'] == '2.0' - assert data['error']['code'] == -32602 - assert "missing 1 required positional argument: 'tag'" in data['error']['data']['message'] - assert data['error']['message'] == 'Invalid params' - assert data['error']['name'] == 'InvalidParamsError' - - rv = client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createManyColor', - 'params': {'colors': [{'name': 'Blue', 'tag': 'good'}, {'name': 'Red', 'tag': 'bad'}]}, - }, - ) - assert rv.status_code == 200 - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'result': [{'id': 0, 'name': 'Blue', 'tag': 'good'}, {'id': 1, 'name': 'Red', 'tag': 'bad'}], - } - - rv = client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createManyColor', - 'params': {'colors': [{'name': 'Blue', 'tag': 'good'}], 'color': {'name': 'Red', 'tag': 'bad'}}, - }, - ) - assert rv.status_code == 200 - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'result': [{'id': 0, 'name': 'Blue', 'tag': 'good'}, {'id': 1, 'name': 'Red', 'tag': 'bad'}], - } - - rv = client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createManyColor', - 'params': [ - [{'name': 'Blue', 'tag': 'good'}, {'name': 'Red', 'tag': 'bad'}], - {'name': 'Green', 'tag': 'yay'}, - ], - }, - ) - assert rv.status_code == 200 - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'result': [ - {'id': 0, 'name': 'Blue', 'tag': 'good'}, - {'id': 1, 'name': 'Red', 'tag': 'bad'}, - {'id': 2, 'name': 'Green', 'tag': 'yay'}, - ], - } - - rv = client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createManyFixColor', - 'params': {'colors': {'1': {'name': 'Blue', 'tag': 'good'}}}, - }, - ) - assert rv.status_code == 200 - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': [{'id': 1, 'name': 'Blue', 'tag': 'good'}]} - - rv = client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.removeColor', - 'params': {'color': {'id': 1, 'name': 'Blue', 'tag': 'good'}}, - }, - ) - assert rv.status_code == 200 - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': {'id': 1, 'name': 'Blue', 'tag': 'good'}} - - rv = client.post( - '/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removeColor', 'params': {'color': None}} - ) - assert rv.status_code == 200 - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': None} - - rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removeColor', 'params': []}) - assert rv.status_code == 200 - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': None} - - rv = client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.removeColor', - 'params': {'color': {'id': 100, 'name': 'Blue', 'tag': 'good'}}, - }, - ) - assert rv.status_code == 500 - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32000, - 'data': {'color_id': 100, 'reason': 'The color with an ID greater than 10 does not exist.'}, - 'message': 'Server error', - 'name': 'ServerError', - }, - } - - -def test_app_with_dataclass(client: 'FlaskClient') -> None: - rv = client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createCar', - 'params': {'car': {'name': 'Fusca', 'tag': 'blue'}}, - }, - ) - assert rv.status_code == 200 - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': {'id': 1, 'name': 'Fusca', 'tag': 'blue'}} - - rv = client.post( - '/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.createCar', 'params': {'car': {'name': 'Fusca'}}} - ) - assert rv.status_code == 400 - data = rv.get_json() - assert data['id'] == 1 - assert data['jsonrpc'] == '2.0' - assert data['error']['code'] == -32602 - assert "missing 1 required positional argument: 'tag'" in data['error']['data']['message'] - assert data['error']['message'] == 'Invalid params' - assert data['error']['name'] == 'InvalidParamsError' - - rv = client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createManyCar', - 'params': {'cars': [{'name': 'Fusca', 'tag': 'blue'}, {'name': 'Kombi', 'tag': 'yellow'}]}, - }, - ) - assert rv.status_code == 200 - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'result': [{'id': 0, 'name': 'Fusca', 'tag': 'blue'}, {'id': 1, 'name': 'Kombi', 'tag': 'yellow'}], - } - - rv = client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createManyCar', - 'params': {'cars': [{'name': 'Fusca', 'tag': 'blue'}], 'car': {'name': 'Kombi', 'tag': 'yellow'}}, - }, - ) - assert rv.status_code == 200 - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'result': [{'id': 0, 'name': 'Fusca', 'tag': 'blue'}, {'id': 1, 'name': 'Kombi', 'tag': 'yellow'}], - } - - rv = client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createManyCar', - 'params': [ - [{'name': 'Fusca', 'tag': 'blue'}, {'name': 'Kombi', 'tag': 'yellow'}], - {'name': 'Gol', 'tag': 'white'}, - ], - }, - ) - assert rv.status_code == 200 - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'result': [ - {'id': 0, 'name': 'Fusca', 'tag': 'blue'}, - {'id': 1, 'name': 'Kombi', 'tag': 'yellow'}, - {'id': 2, 'name': 'Gol', 'tag': 'white'}, - ], - } - - rv = client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createManyFixCar', - 'params': {'cars': {'1': {'name': 'Fusca', 'tag': 'blue'}}}, - }, - ) - assert rv.status_code == 200 - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': [{'id': 1, 'name': 'Fusca', 'tag': 'blue'}]} - - rv = client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.removeCar', - 'params': {'car': {'id': 1, 'name': 'Fusca', 'tag': 'blue'}}, - }, - ) - assert rv.status_code == 200 - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': {'id': 1, 'name': 'Fusca', 'tag': 'blue'}} - - rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removeCar', 'params': {'car': None}}) - assert rv.status_code == 200 - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': None} - - rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removeCar', 'params': []}) - assert rv.status_code == 200 - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': None} - - rv = client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.removeCar', - 'params': {'car': {'id': 100, 'name': 'Fusca', 'tag': 'blue'}}, - }, - ) - assert rv.status_code == 500 - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32000, - 'data': {'car_id': 100, 'reason': 'The car with an ID greater than 10 does not exist.'}, - 'message': 'Server error', - 'name': 'ServerError', - }, - } - - -def test_app_with_pydantic(client: 'FlaskClient') -> None: - rv = client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createPet', - 'params': {'pet': {'name': 'Eve', 'tag': 'dog'}}, - }, - ) - assert rv.status_code == 200 - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': {'id': 1, 'name': 'Eve', 'tag': 'dog'}} - - rv = client.post( - '/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.createPet', 'params': {'pet': {'name': 'Eve'}}} - ) - assert rv.status_code == 400 - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32602, - 'data': { - 'message': '1 validation error for NewPet\n' - 'tag\n' - " Field required [type=missing, input_value={'name': 'Eve'}, " - 'input_type=dict]\n' - ' For further information visit ' - 'https://errors.pydantic.dev/2.9/v/missing' - }, - 'message': 'Invalid params', - 'name': 'InvalidParamsError', - }, - } - - rv = client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createManyPet', - 'params': {'pets': [{'name': 'Eve', 'tag': 'dog'}, {'name': 'Lou', 'tag': 'dog'}]}, - }, - ) - assert rv.status_code == 200 - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'result': [{'id': 0, 'name': 'Eve', 'tag': 'dog'}, {'id': 1, 'name': 'Lou', 'tag': 'dog'}], - } - - rv = client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createManyPet', - 'params': {'pets': [{'name': 'Eve', 'tag': 'dog'}], 'pet': {'name': 'Lou', 'tag': 'dog'}}, - }, - ) - assert rv.status_code == 200 - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'result': [{'id': 0, 'name': 'Eve', 'tag': 'dog'}, {'id': 1, 'name': 'Lou', 'tag': 'dog'}], - } - - rv = client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createManyPet', - 'params': [ - [{'name': 'Eve', 'tag': 'dog'}, {'name': 'Lou', 'tag': 'dog'}], - {'name': 'Tequila', 'tag': 'cat'}, - ], - }, - ) - assert rv.status_code == 200 - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'result': [ - {'id': 0, 'name': 'Eve', 'tag': 'dog'}, - {'id': 1, 'name': 'Lou', 'tag': 'dog'}, - {'id': 2, 'name': 'Tequila', 'tag': 'cat'}, - ], - } - - rv = client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.createManyFixPet', - 'params': {'pets': {'1': {'name': 'Eve', 'tag': 'dog'}}}, - }, - ) - assert rv.status_code == 200 - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': [{'id': 1, 'name': 'Eve', 'tag': 'dog'}]} - - rv = client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.removePet', - 'params': {'pet': {'id': 1, 'name': 'Eve', 'tag': 'dog'}}, - }, - ) - assert rv.status_code == 200 - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': {'id': 1, 'name': 'Eve', 'tag': 'dog'}} - - rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removePet', 'params': {'pet': None}}) - assert rv.status_code == 200 - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': None} - - rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removePet', 'params': []}) - assert rv.status_code == 200 - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': None} - - rv = client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.removePet', - 'params': {'pet': {'id': 100, 'name': 'Lou', 'tag': 'dog'}}, - }, - ) - assert rv.status_code == 500 - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32000, - 'data': {'pet_id': 100, 'reason': 'The pet with an ID greater than 10 does not exist.'}, - 'message': 'Server error', - 'name': 'ServerError', - }, - } - - -def test_app_system_describe(client: 'FlaskClient') -> None: - rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'rpc.describe'}) - data = rv.get_json() - assert data['id'] == 1 - assert data['jsonrpc'] == '2.0' - assert data['result']['name'] == 'Flask-JSONRPC' - assert data['result']['version'] == '2.0' - assert data['result']['servers'] is not None - assert 'url' in data['result']['servers'][0] - assert data['result']['methods'] == { - 'jsonrpc.greeting': { - 'type': 'method', - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'name', 'type': 'String'}], - 'returns': {'type': 'String'}, - }, - 'jsonrpc.echo': { - 'type': 'method', - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'string', 'type': 'String'}, {'name': '_some', 'type': 'Object'}], - 'returns': {'type': 'String'}, - }, - 'jsonrpc.notify': { - 'type': 'method', - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': '_string', 'type': 'String'}], - 'returns': {'type': 'Null'}, - }, - 'jsonrpc.not_allow_notify': { - 'type': 'method', - 'options': {'notification': False, 'validate': True}, - 'params': [{'name': '_string', 'type': 'String'}], - 'returns': {'type': 'String'}, - }, - 'jsonrpc.fails': { - 'type': 'method', - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'n', 'type': 'Number'}], - 'returns': {'type': 'Number'}, - }, - 'jsonrpc.strangeEcho': { - 'type': 'method', - 'options': {'notification': True, 'validate': True}, - 'params': [ - {'name': 'string', 'type': 'String'}, - {'name': 'omg', 'type': 'Object'}, - {'name': 'wtf', 'type': 'Array'}, - {'name': 'nowai', 'type': 'Number'}, - {'name': 'yeswai', 'type': 'String'}, - ], - 'returns': {'type': 'Array'}, - }, - 'jsonrpc.sum': { - 'type': 'method', - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'a', 'type': 'Number'}, {'name': 'b', 'type': 'Number'}], - 'returns': {'type': 'Number'}, - }, - 'jsonrpc.createCar': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'car', 'type': 'Object'}], - 'returns': {'type': 'Object'}, - 'type': 'method', - }, - 'jsonrpc.createColor': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'color', 'type': 'Object'}], - 'returns': {'type': 'Object'}, - 'type': 'method', - }, - 'jsonrpc.createManyCar': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'cars', 'type': 'Array'}, {'name': 'car', 'type': 'Object'}], - 'returns': {'type': 'Array'}, - 'type': 'method', - }, - 'jsonrpc.createManyColor': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'colors', 'type': 'Array'}, {'name': 'color', 'type': 'Object'}], - 'returns': {'type': 'Array'}, - 'type': 'method', - }, - 'jsonrpc.createManyFixCar': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'cars', 'type': 'Object'}], - 'returns': {'type': 'Array'}, - 'type': 'method', - }, - 'jsonrpc.createManyFixColor': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'colors', 'type': 'Object'}], - 'returns': {'type': 'Array'}, - 'type': 'method', - }, - 'jsonrpc.createManyFixPet': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'pets', 'type': 'Object'}], - 'returns': {'type': 'Array'}, - 'type': 'method', - }, - 'jsonrpc.createManyPet': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'pets', 'type': 'Array'}, {'name': 'pet', 'type': 'Object'}], - 'returns': {'type': 'Array'}, - 'type': 'method', - }, - 'jsonrpc.createPet': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'pet', 'type': 'Object'}], - 'returns': {'type': 'Object'}, - 'type': 'method', - }, - 'jsonrpc.decorators': { - 'type': 'method', - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'string', 'type': 'String'}], - 'returns': {'type': 'String'}, - }, - 'jsonrpc.decoratorsWrapped': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'string', 'type': 'String'}], - 'returns': {'type': 'String'}, - 'type': 'method', - }, - 'jsonrpc.returnStatusCode': { - 'type': 'method', - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 's', 'type': 'String'}], - 'returns': {'type': 'Array'}, - }, - 'jsonrpc.removeCar': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'car', 'type': 'Object'}], - 'returns': {'type': 'Object'}, - 'type': 'method', - }, - 'jsonrpc.removeColor': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'color', 'type': 'Object'}], - 'returns': {'type': 'Object'}, - 'type': 'method', - }, - 'jsonrpc.removePet': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'pet', 'type': 'Object'}], - 'returns': {'type': 'Object'}, - 'type': 'method', - }, - 'jsonrpc.returnHeaders': { - 'type': 'method', - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 's', 'type': 'String'}], - 'returns': {'type': 'Array'}, - }, - 'jsonrpc.returnStatusCodeAndHeaders': { - 'type': 'method', - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 's', 'type': 'String'}], - 'returns': {'type': 'Array'}, - }, - 'jsonrpc.not_validate': { - 'type': 'method', - 'options': {'notification': True, 'validate': False}, - 'params': [{'name': 's', 'type': 'Object'}], - 'returns': {'type': 'Object'}, - }, - 'jsonrpc.invalidUnion1': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'color', 'type': 'Object'}], - 'returns': {'type': 'Object'}, - 'type': 'method', - }, - 'jsonrpc.invalidUnion2': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'color', 'type': 'Object'}], - 'returns': {'type': 'Object'}, - 'type': 'method', - }, - 'jsonrpc.literalType': { - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'x', 'type': 'Object'}], - 'returns': {'type': 'Object'}, - 'type': 'method', - }, - 'jsonrpc.mixin_not_validate': { - 'type': 'method', - 'options': {'notification': True, 'validate': False}, - 'params': [ - {'name': 's', 'type': 'Object'}, - {'name': 't', 'type': 'Number'}, - {'name': 'u', 'type': 'Object'}, - {'name': 'v', 'type': 'String'}, - {'name': 'x', 'type': 'Object'}, - {'name': 'z', 'type': 'Object'}, - ], - 'returns': {'type': 'Object'}, - }, - 'jsonrpc.noReturn': { - 'type': 'method', - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': '_string', 'type': 'String'}], - 'returns': {'type': 'Null'}, - }, - 'classapp.index': { - 'type': 'method', - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'name', 'type': 'String'}], - 'returns': {'type': 'String'}, - }, - 'greeting': { - 'type': 'method', - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'name', 'type': 'String'}], - 'returns': {'type': 'String'}, - }, - 'hello': { - 'type': 'method', - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'name', 'type': 'String'}], - 'returns': {'type': 'String'}, - }, - 'echo': { - 'type': 'method', - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'string', 'type': 'String'}, {'name': '_some', 'type': 'Object'}], - 'returns': {'type': 'String'}, - }, - 'notify': { - 'type': 'method', - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': '_string', 'type': 'String'}], - 'returns': {'type': 'Null'}, - }, - 'not_allow_notify': { - 'type': 'method', - 'options': {'notification': False, 'validate': True}, - 'params': [{'name': '_string', 'type': 'String'}], - 'returns': {'type': 'String'}, - }, - 'fails': { - 'type': 'method', - 'options': {'notification': True, 'validate': True}, - 'params': [{'name': 'n', 'type': 'Number'}], - 'returns': {'type': 'Number'}, - }, - 'rpc.describe': {'options': {}, 'params': [], 'returns': {'type': 'Object'}, 'type': 'method'}, - } - - assert rv.status_code == 200 diff --git a/tests/unit/test_encoders.py b/tests/unit/test_encoders.py index fdb3a9c0..d17b4173 100644 --- a/tests/unit/test_encoders.py +++ b/tests/unit/test_encoders.py @@ -26,16 +26,17 @@ # POSSIBILITY OF SUCH DAMAGE. import sys from enum import Enum -import typing as t from pathlib import Path from collections import deque from dataclasses import dataclass +from flask import Flask + from pydantic.main import BaseModel import pytest -from flask_jsonrpc.encoders import serializable +from flask_jsonrpc.encoders import jsonify, serializable # Python 3.10+ try: @@ -60,24 +61,51 @@ def __init__(self: Self) -> None: class DataClassType: x: str y: int - z: t.List[str] + z: list[str] class PydanticType(BaseModel): x: str y: int - z: t.List[str] + z: list[str] -def test_serializable() -> None: - assert serializable(None) is None +def test_serializable_simple() -> None: assert serializable('') == '' + assert serializable(b'') == '' assert serializable(1) == 1 + + +def test_serializable_none() -> None: + assert serializable(None) is None + + +def test_serializable_enum() -> None: assert serializable(EnumType.X) == 'x' + + +def test_serializable_dict() -> None: assert serializable({'key1': 'value1', 'key2': EnumType.X}) == {'key1': 'value1', 'key2': 'x'} + + +def test_serializable_list() -> None: assert serializable([1, 2, EnumType.X, {'key1': 'value1'}]) == [1, 2, 'x', {'key1': 'value1'}] assert serializable(deque(['a', 'b', EnumType.X])) == ['a', 'b', 'x'] + + +def test_serializable_pure_class() -> None: assert serializable(GenericClass()) == {'attr1': 'value1', 'attr2': 2} + + +def test_serializable_dataclass() -> None: + assert serializable(DataClassType(x='str', y=1, z=['0', '1', '2'])) == {'x': 'str', 'y': 1, 'z': ['0', '1', '2']} + + +def test_serializable_pydatic_model() -> None: + assert serializable(PydanticType(x='str', y=1, z=['0', '1', '2'])) == {'x': 'str', 'y': 1, 'z': ['0', '1', '2']} + + +def test_serializable_comlex_dict() -> None: assert serializable( { 'str': 'x', @@ -97,6 +125,9 @@ def test_serializable() -> None: 'pydantic': {'x': 'str', 'y': 1, 'z': ['0', '1', '2']}, 'str': 'x', } + + +def test_serializable_complex_list() -> None: assert serializable( [ 'x', @@ -127,3 +158,13 @@ def test_serializable_path() -> None: @pytest.mark.skipif(sys.platform != 'win32', reason='does run on windows') def test_serializable_path_win32() -> None: assert serializable(Path('/')) == '\\' + + +def test_jsonify_simple() -> None: + app = Flask('jsonify') + + with app.app_context(): + assert jsonify('').response == [b'""\n'] + assert jsonify(1).response == [b'1\n'] + assert jsonify({}).response == [b'{}\n'] + assert jsonify({'key': 1}).response == [b'{"key":1}\n'] diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py index 92edbdd2..1ab4704a 100644 --- a/tests/unit/test_exceptions.py +++ b/tests/unit/test_exceptions.py @@ -24,7 +24,6 @@ # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. - from flask_jsonrpc.exceptions import ( ParseError, ServerError, diff --git a/tests/unit/test_funcutils.py b/tests/unit/test_funcutils.py index ff70f2d5..0f94591f 100644 --- a/tests/unit/test_funcutils.py +++ b/tests/unit/test_funcutils.py @@ -24,14 +24,18 @@ # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. +from enum import Enum import typing as t +from decimal import Decimal from dataclasses import asdict, dataclass +from typing_extensions import Literal, LiteralString + from pydantic.main import BaseModel import pytest -from flask_jsonrpc.funcutils import loads +from flask_jsonrpc.funcutils import loads, bindfy # Python 3.10+ try: @@ -46,33 +50,60 @@ def __init__(self: Self, attr1: str, attr2: int) -> None: self.attr2 = attr2 +class NamedTupleType(t.NamedTuple): + x: str + y: int + z: list[str] + + +class EnumType(Enum): + RED = 1 + BLUE = 2 + + @dataclass class DataClassType: x: str y: int - z: t.List[str] + z: list[str] class PydanticType(BaseModel): x: str y: int - z: t.List[str] + z: list[str] -def test_loads() -> None: +def test_loads_simple() -> None: assert loads(str, None) is None assert loads(t.Any, None) is None assert loads(t.Any, 42) == 42 assert loads(t.Any, 'test') == 'test' assert loads(str, 'string') == 'string' + assert loads(bytes, 'string') == b'string' assert loads(int, 1) == 1 + + +def test_loads_none() -> None: + assert loads(None, None) is None + assert loads(Literal[None], None) is None + + +def test_loads_optinal() -> None: assert loads(t.Optional[int], 1) == 1 assert loads(t.Optional[int], None) is None + assert loads(t.Optional[str], 'Lou') == 'Lou' + + +def test_loads_union() -> None: assert loads(t.Union[int, None], None) is None assert loads(t.Union[int, None], 1) == 1 assert loads(t.Union[None, int], None) is None assert loads(t.Union[None, int], 1) == 1 assert loads(t.Union[None, None], None) is None + + +def test_loads_invalid_union_types() -> None: with pytest.raises( TypeError, match='the only type of union that is supported is: typing.Union\\[T, None\\] or typing.Optional\\[T\\]', @@ -83,35 +114,92 @@ def test_loads() -> None: match='the only type of union that is supported is: typing.Union\\[T, None\\] or typing.Optional\\[T\\]', ): loads(t.Union[int, str], 1) - assert loads(t.List[int], [1, 2, 3, 4, 5]) == [1, 2, 3, 4, 5] - assert loads(t.List[t.List[int]], [[1, 2], [3, 4]]) == [[1, 2], [3, 4]] - assert loads(t.Dict[str, int], {'a': 1, 'b': 2, 'c': 3}) == {'a': 1, 'b': 2, 'c': 3} - assert loads(t.Dict[str, t.Dict[str, int]], {'outer': {'inner': 1}}) == {'outer': {'inner': 1}} + + +def test_loads_list() -> None: + assert loads(list[int], [1, 2, 3, 4, 5]) == [1, 2, 3, 4, 5] + assert loads(list[list[int]], [[1, 2], [3, 4]]) == [[1, 2], [3, 4]] + + +def test_loads_dict() -> None: + assert loads(dict[str, int], {'a': 1, 'b': 2, 'c': 3}) == {'a': 1, 'b': 2, 'c': 3} + assert loads(dict[str, dict[str, int]], {'outer': {'inner': 1}}) == {'outer': {'inner': 1}} + + +def test_loads_literal() -> None: + assert loads(Literal[True], True) + assert loads(Literal['hello', 'world'], 'hello') == 'hello' + assert loads(LiteralString, 'hello') == 'hello' + + +def test_loads_final() -> None: + assert loads(t.Final[bool], True) + assert loads(t.Final[int], 10) == 10 + + +def test_loads_enum() -> None: + assert loads(EnumType, 1) == EnumType.RED + + +def test_loads_decimal() -> None: + assert loads(Decimal, '1.23') == Decimal('1.23') + + +def test_loads_pure_class() -> None: assert loads(GenericClass, {'attr1': 'value1', 'attr2': 2}).__dict__ == GenericClass('value1', 2).__dict__ + + +def test_loads_namedtuple() -> None: + assert loads(NamedTupleType, {'x': 'str', 'y': 1, 'z': ['a', 'b', 'c']}) == NamedTupleType( + x='str', y=1, z=['a', 'b', 'c'] + ) + + +def test_loads_pydantic_model() -> None: assert ( loads(PydanticType, {'x': 'str', 'y': 1, 'z': ['0', '1', '2']}).model_dump() == PydanticType(x='str', y=1, z=['0', '1', '2']).model_dump() ) - assert [x.model_dump() for x in loads(t.List[PydanticType], [{'x': 'str', 'y': 1, 'z': ['0', '1', '2']}])] == [ - PydanticType(x='str', y=1, z=['0', '1', '2']).model_dump() - ] + + +def test_loads_invalid_pydantic_model() -> None: with pytest.raises(TypeError) as excinfo: loads(PydanticType, {'invalid_key': 'value'}) assert "Field required [type=missing, input_value={'invalid_key': 'value'}, input_type=dict]" in str(excinfo.value) + + +def test_loads_complex_list() -> None: + assert [x.model_dump() for x in loads(list[PydanticType], [{'x': 'str', 'y': 1, 'z': ['0', '1', '2']}])] == [ + PydanticType(x='str', y=1, z=['0', '1', '2']).model_dump() + ] + assert [asdict(x) for x in loads(list[DataClassType], [{'x': 'str', 'y': 1, 'z': ['0', '1', '2']}])] == [ + asdict(DataClassType(x='str', y=1, z=['0', '1', '2'])) + ] + + +def test_loads_complex_dict() -> None: assert { k: v.model_dump() - for k, v in loads(t.Dict[str, PydanticType], {'obj': {'x': 'str', 'y': 1, 'z': ['0', '1', '2']}}).items() + for k, v in loads(dict[str, PydanticType], {'obj': {'x': 'str', 'y': 1, 'z': ['0', '1', '2']}}).items() } == {'obj': PydanticType(x='str', y=1, z=['0', '1', '2']).model_dump()} assert asdict(loads(DataClassType, {'x': 'str', 'y': 1, 'z': ['0', '1', '2']})) == asdict( DataClassType(x='str', y=1, z=['0', '1', '2']) ) - assert [asdict(x) for x in loads(t.List[DataClassType], [{'x': 'str', 'y': 1, 'z': ['0', '1', '2']}])] == [ - asdict(DataClassType(x='str', y=1, z=['0', '1', '2'])) - ] assert { k: asdict(v) - for k, v in loads(t.Dict[str, DataClassType], {'obj': {'x': 'str', 'y': 1, 'z': ['0', '1', '2']}}).items() + for k, v in loads(dict[str, DataClassType], {'obj': {'x': 'str', 'y': 1, 'z': ['0', '1', '2']}}).items() } == {'obj': asdict(DataClassType(x='str', y=1, z=['0', '1', '2']))} with pytest.raises(TypeError) as excinfo: loads(DataClassType, {'invalid_key': 'value'}) assert "__init__() got an unexpected keyword argument 'invalid_key'" in str(excinfo.value) + + +def test_bindfy() -> None: + def view_func(name: str) -> str: + return f'Hello {name}' + + fn_annotations = t.get_type_hints(view_func) + setattr(view_func, 'jsonrpc_method_return', fn_annotations.pop('return', None)) # noqa: B010 + setattr(view_func, 'jsonrpc_method_params', fn_annotations) # noqa: B010 + + bindfy(view_func, {'name': 'Eve'}) diff --git a/tests/unit/test_helpers.py b/tests/unit/test_helpers.py index fccfbc71..4af4c8a2 100644 --- a/tests/unit/test_helpers.py +++ b/tests/unit/test_helpers.py @@ -25,21 +25,71 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import typing as t +from dataclasses import dataclass + +from pydantic import BaseModel import pytest -from flask_jsonrpc.helpers import urn, from_python_type +from flask_jsonrpc.helpers import get, urn, from_python_type + + +def test_urn_valid_name() -> None: + assert urn('a', 'b', 'c', 'd') == 'urn:a:b:c:d' + + +def test_urn_valid_name_and_no_args() -> None: + assert urn('example') == 'urn:example' + + +def test_urn_valid_name_with_single_arg() -> None: + assert urn('example', 'path/to/resource') == 'urn:example:path:to:resource' + + +def test_urn_valid_name_with_multiple_args() -> None: + assert urn('example', 'path/to/resource', 'another/path') == 'urn:example:path:to:resource:another:path' + + +def test_urn_valid_name_with_empty_string_arg() -> None: + assert urn('example', '') == 'urn:example' + + +def test_urn_valid_name_with_leading_trailing_slashes() -> None: + assert urn('example', '/path/to/resource/') == 'urn:example:path:to:resource' -def test_urn() -> None: +def test_urn_valid_name_with_multiple_slashes() -> None: + assert urn('example', 'path//to//resource') == 'urn:example:path:to:resource' + + +def test_urn_valid_name_with_empty_parts() -> None: + assert urn('example', 'path//to//resource//') == 'urn:example:path:to:resource' + + +def test_urn_valid_name_with_special_characters() -> None: + assert urn('example', 'path/with/special!@#$%&*()chars') == 'urn:example:path:with:special!@#$%&*()chars' + + +def test_urn_invalid_name() -> None: with pytest.raises(ValueError, match='name is required'): urn(None) with pytest.raises(ValueError, match='name is required'): urn('') - assert urn('a', 'b', 'c', 'd') == 'urn:a:b:c:d' -def test_from_python_type() -> None: +def test_urn_combined_slashes() -> None: + assert urn('example', 'path///to///resource') == 'urn:example:path:to:resource' + + +def test_urn_leading_trailing_colons() -> None: + assert urn('example', '::path::to::resource::') == 'urn:example:path:to:resource' + + +def test_urn_case_insensitivity() -> None: + assert urn('Example') == 'urn:example' + + +def test_from_python_type_simple() -> None: assert str(from_python_type(str)) == 'String' assert str(from_python_type(t.AnyStr)) == 'String' assert str(from_python_type(int)) == 'Number' @@ -50,3 +100,84 @@ def test_from_python_type() -> None: assert str(from_python_type(bool)) == 'Boolean' assert str(from_python_type(type(None))) == 'Null' assert str(from_python_type(type)) == 'Object' + assert str(from_python_type(t.NoReturn)) == 'Null' + + +def test_from_python_type_with_pure_class() -> None: + class ClassTest: + attr1: str + attr2: int + + assert str(from_python_type(ClassTest)) == 'Object' + + +def test_from_python_type_with_dataclass() -> None: + @dataclass + class ClassTest: + attr1: str + attr2: int + + assert str(from_python_type(ClassTest)) == 'Object' + + +def test_from_python_type_with_pydantic_model() -> None: + class ClassTest(BaseModel): + attr1: str + attr2: int + + assert str(from_python_type(ClassTest)) == 'Object' + + +def test_get_none_obj() -> None: + assert get(None, 'a') is None + assert get(None, 'a', 'default') == 'default' + + +def test_get_non_dict_obj() -> None: + assert get('a', 'a.b.c', 'default') == 'default' + assert get([], 'a.b.c', 'default') == 'default' + assert get(123, 'a.b.c', 'default') == 'default' + + +def test_get_existing_key() -> None: + obj = {'a': 1} + assert get(obj, 'a') == 1 + assert get(obj, 'b', 'default') == 'default' + + +def test_get_nested_keys() -> None: + obj = {'a': {'b': {'c': 1}}} + assert get(obj, 'a.b.c') == 1 + assert get(obj, 'a.b.d', 'default') == 'default' + assert get(obj, 'a.d.c', 'default') == 'default' + + +def test_get_empty_dict() -> None: + assert get({}, 'a.b.c') is None + assert get({}, 'a.b.c', 'default') == 'default' + + +def test_get_empty_list() -> None: + assert get([], 'a.b.c') is None + assert get([], 'a.b.c', 'default') == 'default' + + +def test_get_complex_structure() -> None: + obj = {'a': {'b': {'c': 1, 'd': 2}}, 'e': 3} + assert get(obj, 'a.b.c') == 1 + assert get(obj, 'a.b.d') == 2 + assert get(obj, 'e') == 3 + assert get(obj, 'a.b.e', 'default') == 'default' + + +def test_get_with_non_existent_path() -> None: + obj = {'x': {'y': 5}} + assert get(obj, 'x.y') == 5 + assert get(obj, 'x.z', 'default') == 'default' + assert get(obj, 'a.b.c', 'default') == 'default' + + +def test_get_with_special_characters() -> None: + obj = {'a.b': {'c.d': 1}} + assert get(obj, 'a.b.c.d', 'default') == 'default' + assert get(obj, 'a.b', 'default') == {'c.d': 1} diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py index f9a3f7c8..20647683 100644 --- a/tests/unit/test_settings.py +++ b/tests/unit/test_settings.py @@ -24,14 +24,17 @@ # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. - import pytest from flask_jsonrpc.settings import JSONRPCSettings -def test_settings() -> None: +def test_settings_simple() -> None: settings = JSONRPCSettings({'setting': True}) assert settings.setting is True + + +def test_settings_invalid_attr() -> None: + settings = JSONRPCSettings({'setting': True}) with pytest.raises(AttributeError, match="invalid setting: 'xxx'"): assert settings.xxx is None diff --git a/tests/unit/test_site.py b/tests/unit/test_site.py new file mode 100644 index 00000000..b446ca73 --- /dev/null +++ b/tests/unit/test_site.py @@ -0,0 +1,350 @@ +# Copyright (c) 2024-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the Cenobit Technologies nor the names of +# its contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +import typing as t + +from flask import Flask + +import pytest +from werkzeug.datastructures import Headers + +from flask_jsonrpc.site import JSONRPCSite +from flask_jsonrpc.exceptions import ParseError + + +def test_site_simple() -> None: + def view_func() -> str: + return 'Hello world!' + + app = Flask('site') + jsonrpc_site = JSONRPCSite(path='/path', base_url='/base') + jsonrpc_site.register('app.view_func', view_func=view_func) + + with app.test_request_context( + '/base/path', method='POST', json={'id': 1, 'jsonrpc': '2.0', 'method': 'app.view_func', 'params': []} + ): + rv, status_code, headers = jsonrpc_site.dispatch_request() + assert rv == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello world!'} + assert status_code == 200 + assert headers == {} + + +def test_site_with_request_using_list_as_params() -> None: + def view_func(a: str, b: int, c: bool) -> str: + return f'Params: {a}, {b}, {c}' + + view_func.jsonrpc_method_return = str + view_func.jsonrpc_method_params = {'a': str, 'b': int, 'c': bool} + + app = Flask('site') + jsonrpc_site = JSONRPCSite(path='/path', base_url='/base') + jsonrpc_site.register('app.view_func', view_func=view_func) + + with app.test_request_context( + '/base/path', + method='POST', + json={'id': 1, 'jsonrpc': '2.0', 'method': 'app.view_func', 'params': ['str', 1, True]}, + ): + rv, status_code, headers = jsonrpc_site.dispatch_request() + assert rv == {'id': 1, 'jsonrpc': '2.0', 'result': 'Params: str, 1, True'} + assert status_code == 200 + assert headers == {} + + +def test_site_with_request_using_dict_as_params() -> None: + def view_func(a: str, b: int, c: bool) -> str: + return f'Params: {a}, {b}, {c}' + + view_func.jsonrpc_method_return = str + view_func.jsonrpc_method_params = {'a': str, 'b': int, 'c': bool} + + app = Flask('site') + jsonrpc_site = JSONRPCSite(path='/path', base_url='/base') + jsonrpc_site.register('app.view_func', view_func=view_func) + + with app.test_request_context( + '/base/path', + method='POST', + json={'id': 1, 'jsonrpc': '2.0', 'method': 'app.view_func', 'params': {'a': 'str', 'b': 1, 'c': True}}, + ): + rv, status_code, headers = jsonrpc_site.dispatch_request() + assert rv == {'id': 1, 'jsonrpc': '2.0', 'result': 'Params: str, 1, True'} + assert status_code == 200 + assert headers == {} + + +def test_site_with_invalid_request() -> None: + app = Flask('site') + jsonrpc_site = JSONRPCSite(path='/path', base_url='/base') + + with app.test_request_context('/base/path', method='POST', data='XXX'), pytest.raises(ParseError): + jsonrpc_site.dispatch_request() + + +def test_site_with_invalid_json_request() -> None: + app = Flask('site') + jsonrpc_site = JSONRPCSite(path='/path', base_url='/base') + + with ( + app.test_request_context( + '/base/path', + method='POST', + data='{"id": 1, "jsonrpc": "2.0", "method": "app.view_func", "params" []}', + headers={'Content-Type': 'application/json'}, + ), + pytest.raises(ParseError), + ): + jsonrpc_site.dispatch_request() + + +def test_site_with_invalid_jsonrpc_request_object() -> None: + app = Flask('site') + jsonrpc_site = JSONRPCSite(path='/path', base_url='/base') + + with app.test_request_context('/base/path', method='POST', json={'id': 1, 'jsonrpc': '2.0'}): + rv, status_code, headers = jsonrpc_site.dispatch_request() + assert rv == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32600, + 'data': {'message': "Invalid JSON: {'id': 1, 'jsonrpc': '2.0'}"}, + 'message': 'Invalid Request', + 'name': 'InvalidRequestError', + }, + } + assert status_code == 400 + assert headers == {} + + +def test_site_with_invalid_params() -> None: + def view_func() -> str: + return 'Hello world!' + + app = Flask('site') + jsonrpc_site = JSONRPCSite(path='/path', base_url='/base') + jsonrpc_site.register('app.view_func', view_func=view_func) + + with app.test_request_context( + '/base/path', method='POST', json={'id': 1, 'jsonrpc': '2.0', 'method': 'app.view_func', 'params': 'invalid'} + ): + rv, status_code, headers = jsonrpc_site.dispatch_request() + assert rv == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32602, + 'data': {'message': 'Parameter structures are by-position (list) or by-name (dict): invalid'}, + 'message': 'Invalid params', + 'name': 'InvalidParamsError', + }, + } + assert status_code == 400 + assert headers == {} + + +def test_site_with_no_view_func_registered() -> None: + app = Flask('site') + jsonrpc_site = JSONRPCSite(path='/path', base_url='/base') + + with app.test_request_context( + '/base/path', method='POST', json={'id': 1, 'jsonrpc': '2.0', 'method': 'app.view_func', 'params': []} + ): + rv, status_code, headers = jsonrpc_site.dispatch_request() + assert rv == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32601, + 'data': {'message': 'Method not found: app.view_func'}, + 'message': 'Method not found', + 'name': 'MethodNotFoundError', + }, + } + assert status_code == 400 + assert headers == {} + + +def test_site_with_view_func_returns_response_and_status_code() -> None: + def view_func() -> tuple[str, int]: + return 'Hello world!', 200 + + app = Flask('site') + jsonrpc_site = JSONRPCSite(path='/path', base_url='/base') + jsonrpc_site.register('app.view_func', view_func=view_func) + + with app.test_request_context( + '/base/path', method='POST', json={'id': 1, 'jsonrpc': '2.0', 'method': 'app.view_func', 'params': []} + ): + rv, status_code, headers = jsonrpc_site.dispatch_request() + assert rv == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello world!'} + assert status_code == 200 + assert headers == {} + + +def test_site_with_view_func_returns_response_and_headers() -> None: + def view_func() -> tuple[str, dict[str, t.Any]]: + return 'Hello world!', {'X-Some-Attr': 'something'} + + app = Flask('site') + jsonrpc_site = JSONRPCSite(path='/path', base_url='/base') + jsonrpc_site.register('app.view_func', view_func=view_func) + + with app.test_request_context( + '/base/path', method='POST', json={'id': 1, 'jsonrpc': '2.0', 'method': 'app.view_func', 'params': []} + ): + rv, status_code, headers = jsonrpc_site.dispatch_request() + assert rv == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello world!'} + assert status_code == 200 + assert headers == {'X-Some-Attr': 'something'} + + +def test_site_with_view_func_returns_response_and_status_code_and_heders() -> None: + def view_func() -> tuple[str, int, dict[str, t.Any]]: + return 'Hello world!', 200, {'X-Some-Attr': 'something'} + + app = Flask('site') + jsonrpc_site = JSONRPCSite(path='/path', base_url='/base') + jsonrpc_site.register('app.view_func', view_func=view_func) + + with app.test_request_context( + '/base/path', method='POST', json={'id': 1, 'jsonrpc': '2.0', 'method': 'app.view_func', 'params': []} + ): + rv, status_code, headers = jsonrpc_site.dispatch_request() + assert rv == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello world!'} + assert status_code == 200 + assert headers == {'X-Some-Attr': 'something'} + + +def test_site_with_batch_request() -> None: + def view_func() -> str: + return 'Hello world!' + + app = Flask('site') + jsonrpc_site = JSONRPCSite(path='/path', base_url='/base') + jsonrpc_site.register('app.view_func', view_func=view_func) + + with app.test_request_context( + '/base/path', + method='POST', + json=[ + {'id': 1, 'jsonrpc': '2.0', 'method': 'app.view_func', 'params': []}, + {'id': 2, 'jsonrpc': '2.0', 'method': 'app.view_func', 'params': []}, + {'id': 3, 'jsonrpc': '2.0', 'method': 'app.view_func', 'params': []}, + ], + ): + rv, status_code, headers = jsonrpc_site.dispatch_request() + assert rv == [ + {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello world!'}, + {'id': 2, 'jsonrpc': '2.0', 'result': 'Hello world!'}, + {'id': 3, 'jsonrpc': '2.0', 'result': 'Hello world!'}, + ] + assert status_code == 200 + assert headers == Headers([]) + + +def test_site_register_error_handler() -> None: + def view_func() -> str: + raise ValueError('some error') + + def value_error_handler(ex: ValueError) -> str: + return f'Error: {ex}' + + app = Flask('site') + jsonrpc_site = JSONRPCSite(path='/path', base_url='/base') + jsonrpc_site.register('app.view_func', view_func=view_func) + jsonrpc_site.register_error_handler(ValueError, value_error_handler) + + with app.test_request_context( + '/base/path', method='POST', json={'id': 1, 'jsonrpc': '2.0', 'method': 'app.view_func', 'params': []} + ): + rv, status_code, headers = jsonrpc_site.dispatch_request() + assert rv == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': {'code': -32000, 'data': 'Error: some error', 'message': 'Server error', 'name': 'ServerError'}, + } + assert status_code == 500 + assert headers == {} + + +def test_site_register_error_handler_without_a_handler() -> None: + def view_func() -> str: + raise ValueError('some error') + + app = Flask('site') + jsonrpc_site = JSONRPCSite(path='/path', base_url='/base') + jsonrpc_site.register('app.view_func', view_func=view_func) + + with app.test_request_context( + '/base/path', method='POST', json={'id': 1, 'jsonrpc': '2.0', 'method': 'app.view_func', 'params': []} + ): + rv, status_code, headers = jsonrpc_site.dispatch_request() + assert rv == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32000, + 'data': {'message': 'some error'}, + 'message': 'Server error', + 'name': 'ServerError', + }, + } + assert status_code == 500 + assert headers == {} + + +def test_site_register_error_handler_rasing_an_except_not_registered() -> None: + class MyException(Exception): + pass + + def view_func() -> str: + raise MyException('some type error') + + def value_error_handler(ex: ValueError) -> str: + return f'Error: {ex}' + + app = Flask('site') + jsonrpc_site = JSONRPCSite(path='/path', base_url='/base') + jsonrpc_site.register('app.view_func', view_func=view_func) + jsonrpc_site.register_error_handler(ValueError, value_error_handler) + + with app.test_request_context( + '/base/path', method='POST', json={'id': 1, 'jsonrpc': '2.0', 'method': 'app.view_func', 'params': []} + ): + rv, status_code, headers = jsonrpc_site.dispatch_request() + assert rv == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32000, + 'data': {'message': 'some type error'}, + 'message': 'Server error', + 'name': 'ServerError', + }, + } + assert status_code == 500 + assert headers == {} diff --git a/tests/unit/test_types.py b/tests/unit/test_types.py index 07a06859..ab2a283a 100644 --- a/tests/unit/test_types.py +++ b/tests/unit/test_types.py @@ -29,16 +29,25 @@ from numbers import Real, Number, Complex, Integral, Rational from collections import OrderedDict, defaultdict +from typing_extensions import Literal + import pytest from flask_jsonrpc import types -def test_types() -> None: +def test_types_empty_type() -> None: + empty_type = types.JSONRPCNewType('Empty', ()) + assert not empty_type.check_type(str) + + +def test_types_string() -> None: assert types.String.check_type(str) assert types.String.check_type(t.AnyStr) assert str(types.String) == 'String' + +def test_types_number() -> None: assert types.Number.check_type(int) assert types.Number.check_type(float) assert not types.Number.check_type(complex) @@ -49,56 +58,85 @@ def test_types() -> None: assert not types.Number.check_type(Number) assert str(types.Number) == 'Number' + +def test_types_object() -> None: + assert types.Object.check_type(dict) assert types.Object.check_type(dict) - assert types.Object.check_type(t.Dict) assert types.Object.check_type(t.Any) assert str(types.Object) == 'Object' + +def test_types_array() -> None: assert types.Array.check_type(list) assert types.Array.check_type(tuple) assert types.Array.check_type(set) - assert types.Array.check_type(t.List) - assert types.Array.check_type(t.NamedTuple) - assert types.Array.check_type(t.Set) - assert types.Array.check_type(t.Tuple) + assert types.Array.check_type(list) + assert types.Array.check_type(set) + assert types.Array.check_type(tuple) + assert types.Array.check_type(frozenset) assert types.Array.check_type(frozenset) - assert types.Array.check_type(t.FrozenSet) assert str(types.Array) == 'Array' + +def test_types_boolean() -> None: assert types.Boolean.check_type(bool) assert str(types.Boolean) == 'Boolean' + +def test_types_null() -> None: assert types.Null.check_type(None) assert types.Null.check_type(type(None)) # noqa: E721 assert types.Null.check_type(t.NoReturn) + assert types.Null.check_type(Literal[None]) + assert not types.Object.check_type(Literal[None]) assert str(types.Null) == 'Null' -def test_types_others() -> None: +def test_types_python_mapping() -> None: assert types.Object.check_type(OrderedDict) assert types.Object.check_type(defaultdict) - assert types.Object.check_type(t.DefaultDict) + assert types.Object.check_type(defaultdict) assert types.Object.check_type(t.Mapping) -def test_types_complex() -> None: +def test_types_type_var() -> None: T = t.TypeVar('T') S = t.TypeVar('S', int, float) X = t.TypeVar('X', bound=int) - U = types.Literal[str] - V = t.Final[str] assert types.Object.check_type(T) assert types.Number.check_type(S) - assert types.String.check_type(U) assert types.Number.check_type(X) - assert types.String.check_type(V) + + +def test_types_literal() -> None: + assert types.String.check_type(Literal[str]) + assert types.String.check_type(Literal['hello', 'world']) + assert not types.String.check_type(Literal[1, 2]) + + +def test_types_final() -> None: + assert types.String.check_type(t.Final[str]) + assert not types.String.check_type(t.Final[int]) + + +def test_types_union() -> None: assert types.String.check_type(t.Union[str, None]) assert types.String.check_type(t.Optional[str]) + assert not types.String.check_type(t.Union[str, int]) + assert not types.Number.check_type(t.Union[str, int]) + + +def test_types_custom_type() -> None: + class CustomType: + __supertype__ = str + + custom_type = types.JSONRPCNewType('CustomType', str) + assert custom_type.check_type(CustomType()) def test_types_from_fn() -> None: - def fn(_a: str, _b: int, _c: t.Dict[str, t.Any], _d: t.List[int], _e: t.Any) -> bool: # noqa: ANN401 + def fn(_a: str, _b: int, _c: dict[str, t.Any], _d: list[int], _e: t.Any) -> bool: # noqa: ANN401 return True fn_annotations = t.get_type_hints(fn) @@ -110,9 +148,8 @@ def fn(_a: str, _b: int, _c: t.Dict[str, t.Any], _d: t.List[int], _e: t.Any) -> assert types.Boolean.check_type(fn_annotations['return']) -# pylint: disable=E1136 @pytest.mark.skipif(sys.version_info < (3, 9), reason='requires python3.9 or higher') -def test_generic_type_alias() -> None: +def test_types_generic_type_alias() -> None: T = t.TypeVar('T') assert types.Array.check_type(list[int]) @@ -121,13 +158,12 @@ def test_generic_type_alias() -> None: assert types.Array.check_type(frozenset[int]) assert types.Array.check_type(tuple[int]) assert types.Object.check_type(dict[int, str]) - assert types.Object.check_type(dict[int, T][str]) + assert types.Object.check_type(dict[int, T][str]) # type: ignore assert types.Object.check_type(dict[int, list[int]]) -# pylint: disable=E1131 @pytest.mark.skipif(sys.version_info < (3, 10), reason='requires python3.10 or higher') -def test_union_type_expression() -> None: +def test_types_union_type_expression() -> None: assert types.Array.check_type(list[int | str]) assert types.String.check_type(str | bytearray) assert types.String.check_type(bytearray | str) @@ -136,12 +172,11 @@ def test_union_type_expression() -> None: @pytest.mark.skipif(sys.version_info < (3, 10), reason='requires python3.10 or higher') -def test_none_type() -> None: +def test_types_none_type() -> None: assert types.Null.check_type(type(None)) - assert types.Null.check_type(types.NoneType) -def test_new_type() -> None: +def test_types_new_type() -> None: UserId = t.NewType('UserId', int) UserUid = t.NewType('UserUid', str) diff --git a/tests/unit/test_views.py b/tests/unit/test_views.py new file mode 100644 index 00000000..f750ceb9 --- /dev/null +++ b/tests/unit/test_views.py @@ -0,0 +1,91 @@ +# Copyright (c) 2024-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the Cenobit Technologies nor the names of +# its contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +import typing as t + +from flask import Flask + +from flask_jsonrpc.views import JSONRPCView +from flask_jsonrpc.exceptions import JSONRPCError + +# Python 3.11+ +try: + from typing import Self +except ImportError: # pragma: no cover + from typing_extensions import Self + + +def test_jsonrpc_view_simple() -> None: + class MockJSONRPCSite: + def dispatch_request(self: Self) -> tuple[t.Any, int, dict[str, t.Any]]: + return {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello world!'}, 200, {} + + app = Flask('mehod_view') + app.add_url_rule('/api', view_func=JSONRPCView.as_view('jsonrpc_view', jsonrpc_site=MockJSONRPCSite())) + + with app.test_client() as client: + r = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'app.index', 'params': ['Tequila']}) + assert r.status_code == 200 + assert r.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Hello world!'} + + +def test_jsonrpc_view_with_response_status_code_204() -> None: + class MockJSONRPCSite: + def dispatch_request(self: Self) -> tuple[t.Any, int, dict[str, t.Any]]: + return '', 204, {} + + app = Flask('mehod_view') + app.add_url_rule('/api', view_func=JSONRPCView.as_view('jsonrpc_view', jsonrpc_site=MockJSONRPCSite())) + + with app.test_client() as client: + r = client.post('/api', json={'jsonrpc': '2.0', 'method': 'app.index', 'params': ['Lou']}) + assert r.status_code == 204 + assert r.text == '' + + +def test_jsonrpc_view_with_invalid_request() -> None: + class MockJSONRPCSite: + def dispatch_request(self: Self) -> tuple[t.Any, int, dict[str, t.Any]]: + raise JSONRPCError( + message='Invalid request', code=1001, data={'message': 'Invalid request'}, status_code=500 + ) + + app = Flask('mehod_view') + app.add_url_rule('/api', view_func=JSONRPCView.as_view('jsonrpc_view', jsonrpc_site=MockJSONRPCSite())) + + with app.test_client() as client: + r = client.post('/api', data={'id': 1, 'jsonrpc': '2.0', 'method': 'app.index', 'params': ['Eve']}) + assert r.status_code == 500 + assert r.json == { + 'error': { + 'code': 1001, + 'data': {'message': ('Invalid request')}, + 'message': 'Invalid request', + 'name': 'JSONRPCError', + }, + 'id': None, + 'jsonrpc': '2.0', + } diff --git a/tests/unit/test_wrappers.py b/tests/unit/test_wrappers.py new file mode 100644 index 00000000..ede0abcd --- /dev/null +++ b/tests/unit/test_wrappers.py @@ -0,0 +1,116 @@ +# Copyright (c) 2024-2024, Cenobit Technologies, Inc. http://cenobit.es/ +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the Cenobit Technologies nor the names of +# its contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +import typing as t +from functools import wraps + +from flask_jsonrpc.views import JSONRPCView +from flask_jsonrpc.wrappers import JSONRPCDecoratorMixin + +# Python 3.11+ +try: + from typing import Self +except ImportError: # pragma: no cover + from typing_extensions import Self + + +class MockJSONRPCSite: + def register(self: Self, name: str, view_func: t.Callable[..., t.Any]) -> None: + pass + + def register_error_handler(self: Self, exception: type[Exception], fn: t.Callable[[t.Any], t.Any]) -> None: + pass + + +mock_jsonrpc_site = MockJSONRPCSite() + + +class JSONRPCApp(JSONRPCDecoratorMixin): + def get_jsonrpc_site(self: Self) -> MockJSONRPCSite: + return mock_jsonrpc_site + + def get_jsonrpc_site_api(self: Self) -> type[JSONRPCView]: + raise NotImplementedError('.get_jsonrpc_site_api must be overridden') from None + + +def test_jsonrpc_register_view_function_simple() -> None: + def view_func(name: str) -> str: + return f'Hello {name}' + + jsonrpc_app = JSONRPCApp() + view_func_wrapped = jsonrpc_app.register_view_function(view_func, 'view_func') + assert view_func_wrapped.jsonrpc_method_name == 'view_func' + assert view_func_wrapped.jsonrpc_method_sig == {'name': str} + assert view_func_wrapped.jsonrpc_method_return is str + assert view_func_wrapped.jsonrpc_method_params == {'name': str} + assert view_func_wrapped.jsonrpc_validate is True + assert view_func_wrapped.jsonrpc_notification is True + assert view_func_wrapped.jsonrpc_options == {'notification': True, 'validate': True} + + +def test_jsonrpc_register_view_function_decorated() -> None: + def decorator(fn: t.Callable[[str], str]) -> t.Callable[[str], str]: + def wrapped(name: str) -> str: + return fn(name) + + return wrapped + + @decorator + def view_func(name: str) -> str: + return f'Hello {name}' + + jsonrpc_app = JSONRPCApp() + view_func_wrapped = jsonrpc_app.register_view_function(view_func, 'view_func') + assert view_func_wrapped.jsonrpc_method_name == 'view_func' + assert view_func_wrapped.jsonrpc_method_sig == {'name': str} + assert view_func_wrapped.jsonrpc_method_return is str + assert view_func_wrapped.jsonrpc_method_params == {'name': str} + assert view_func_wrapped.jsonrpc_validate is True + assert view_func_wrapped.jsonrpc_notification is True + assert view_func_wrapped.jsonrpc_options == {'notification': True, 'validate': True} + + +def test_jsonrpc_register_view_function_wrapped_decorator() -> None: + def decorator(fn: t.Callable[[str], str]) -> t.Callable[[str], str]: + @wraps(fn) + def wrapped(name: str) -> str: + return fn(name) + + return wrapped + + @decorator + def view_func(name: str) -> str: + return f'Hello {name}' + + jsonrpc_app = JSONRPCApp() + view_func_wrapped = jsonrpc_app.register_view_function(view_func, 'view_func') + assert view_func_wrapped.jsonrpc_method_name == 'view_func' + assert view_func_wrapped.jsonrpc_method_sig == {'name': str} + assert view_func_wrapped.jsonrpc_method_return is str + assert view_func_wrapped.jsonrpc_method_params == {'name': str} + assert view_func_wrapped.jsonrpc_validate is True + assert view_func_wrapped.jsonrpc_notification is True + assert view_func_wrapped.jsonrpc_options == {'notification': True, 'validate': True} diff --git a/tox.ini b/tox.ini index a8fa505e..339efb16 100644 --- a/tox.ini +++ b/tox.ini @@ -18,7 +18,7 @@ deps = -r requirements/tests.txt async: Flask[async]>=3.0.0,<4.0 commands = - pytest -vv --tb=short --basetemp={envtmpdir} {posargs} + pytest -n auto -vv --tb=short --basetemp={envtmpdir} {posargs} [testenv:style] deps = pre-commit