diff --git a/Pipfile b/Pipfile index 104c27c8..029ca2fb 100644 --- a/Pipfile +++ b/Pipfile @@ -9,6 +9,7 @@ pytest = "*" pylint = "*" [packages] +fastapi = "*" flask = "*" python-dotenv = "*" requests = "*" @@ -16,6 +17,11 @@ gunicorn = "*" flask-cors = "*" cachetools = "*" python-dateutil = "*" +uvicorn = "*" [requires] python_version = "3.8" + +[scripts] +dev = "uvicorn app.main:APP --reload" +start = "uvicorn app.main:APP" diff --git a/Pipfile.lock b/Pipfile.lock index bcc795ad..b79d6b4b 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "3ca964b855d418f59464ea8c7de126e18ab3f8ff5c7142d774468f95d9a1156c" + "sha256": "846c10a9cdea8ecb7482b41acd826e486578f42a7443022155bd6484f104376b" }, "pipfile-spec": 6, "requires": { @@ -45,6 +45,14 @@ ], "version": "==7.1.1" }, + "fastapi": { + "hashes": [ + "sha256:532648b4e16dd33673d71dc0b35dff1b4d20c709d04078010e258b9f3a79771a", + "sha256:721b11d8ffde52c669f52741b6d9d761fe2e98778586f4cfd6f5e47254ba5016" + ], + "index": "pypi", + "version": "==0.52.0" + }, "flask": { "hashes": [ "sha256:13f9f196f330c7c2c5d7a5cf91af894110ca0215ac051b5844701f2bfd934d52", @@ -69,6 +77,31 @@ "index": "pypi", "version": "==20.0.4" }, + "h11": { + "hashes": [ + "sha256:33d4bca7be0fa039f4e84d50ab00531047e53d6ee8ffbc83501ea602c169cae1", + "sha256:4bc6d6a1238b7615b266ada57e0618568066f57dd6fa967d1290ec9309b2f2f1" + ], + "version": "==0.9.0" + }, + "httptools": { + "hashes": [ + "sha256:0a4b1b2012b28e68306575ad14ad5e9120b34fccd02a81eb08838d7e3bbb48be", + "sha256:3592e854424ec94bd17dc3e0c96a64e459ec4147e6d53c0a42d0ebcef9cb9c5d", + "sha256:41b573cf33f64a8f8f3400d0a7faf48e1888582b6f6e02b82b9bd4f0bf7497ce", + "sha256:56b6393c6ac7abe632f2294da53f30d279130a92e8ae39d8d14ee2e1b05ad1f2", + "sha256:86c6acd66765a934e8730bf0e9dfaac6fdcf2a4334212bd4a0a1c78f16475ca6", + "sha256:96da81e1992be8ac2fd5597bf0283d832287e20cb3cfde8996d2b00356d4e17f", + "sha256:96eb359252aeed57ea5c7b3d79839aaa0382c9d3149f7d24dd7172b1bcecb009", + "sha256:a2719e1d7a84bb131c4f1e0cb79705034b48de6ae486eb5297a139d6a3296dce", + "sha256:ac0aa11e99454b6a66989aa2d44bca41d4e0f968e395a0a8f164b401fefe359a", + "sha256:bc3114b9edbca5a1eb7ae7db698c669eb53eb8afbbebdde116c174925260849c", + "sha256:fa3cd71e31436911a44620473e873a256851e1f53dee56669dae403ba41756a4", + "sha256:fea04e126014169384dee76a153d4573d90d0cbd1d12185da089f73c78390437" + ], + "markers": "sys_platform != 'win32' and sys_platform != 'cygwin' and platform_python_implementation != 'PyPy'", + "version": "==0.1.1" + }, "idna": { "hashes": [ "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb", @@ -128,6 +161,25 @@ ], "version": "==1.1.1" }, + "pydantic": { + "hashes": [ + "sha256:012c422859bac2e03ab3151ea6624fecf0e249486be7eb8c6ee69c91740c6752", + "sha256:07911aab70f3bc52bb845ce1748569c5e70478ac977e106a150dd9d0465ebf04", + "sha256:47b8db7024ba3d46c3d4768535e1cf87b6c8cf92ccd81e76f4e1cb8ee47688b3", + "sha256:50e4e948892a6815649ad5a9a9379ad1e5f090f17842ac206535dfaed75c6f2f", + "sha256:51f11c8bbf794a68086540da099aae4a9107447c7a9d63151edbb7d50110cf21", + "sha256:6100d7862371115c40be55cc4b8d766a74b1d0dbaf99dbfe72bb4bac0faf89ed", + "sha256:61d22d36808087d3184ed6ac0d91dd71c533b66addb02e4a9930e1e30833202f", + "sha256:72184c1421103cca128300120f8f1185fb42a9ea73a1c9845b1c53db8c026a7d", + "sha256:831a0265a9e3933b3d0f04d1a81bba543bafbe4119c183ff2771871db70524ab", + "sha256:8848b4eb458469739126e4c1a202d723dd092e087f8dbe3104371335f87ba5df", + "sha256:bbbed364376f4a0aebb9ea452ff7968b306499a9e74f4db69b28ff2cd4043a11", + "sha256:e27559cedbd7f59d2375bfd6eea29a330ea1a5b0589c34d6b4e0d7bec6027bbf", + "sha256:f17ec336e64d4583311249fb179528e9a2c27c8a2eaf590ec6ec2c6dece7cb3f", + "sha256:f863456d3d4bf817f2e5248553dee3974c5dc796f48e6ddb599383570f4215ac" + ], + "version": "==1.4" + }, "python-dateutil": { "hashes": [ "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", @@ -159,6 +211,13 @@ ], "version": "==1.14.0" }, + "starlette": { + "hashes": [ + "sha256:6169ee78ded501095d1dda7b141a1dc9f9934d37ad23196e180150ace2c6449b", + "sha256:a9bb130fa7aa736eda8a814b6ceb85ccf7a209ed53843d0d61e246b380afa10f" + ], + "version": "==0.13.2" + }, "urllib3": { "hashes": [ "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc", @@ -166,6 +225,56 @@ ], "version": "==1.25.8" }, + "uvicorn": { + "hashes": [ + "sha256:0f58170165c4495f563d8224b2f415a0829af0412baa034d6f777904613087fd", + "sha256:6fdaf8e53bf1b2ddf0fe9ed06079b5348d7d1d87b3365fe2549e6de0d49e631c" + ], + "index": "pypi", + "version": "==0.11.3" + }, + "uvloop": { + "hashes": [ + "sha256:08b109f0213af392150e2fe6f81d33261bb5ce968a288eb698aad4f46eb711bd", + "sha256:123ac9c0c7dd71464f58f1b4ee0bbd81285d96cdda8bc3519281b8973e3a461e", + "sha256:4315d2ec3ca393dd5bc0b0089d23101276778c304d42faff5dc4579cb6caef09", + "sha256:4544dcf77d74f3a84f03dd6278174575c44c67d7165d4c42c71db3fdc3860726", + "sha256:afd5513c0ae414ec71d24f6f123614a80f3d27ca655a4fcf6cabe50994cc1891", + "sha256:b4f591aa4b3fa7f32fb51e2ee9fea1b495eb75b0b3c8d0ca52514ad675ae63f7", + "sha256:bcac356d62edd330080aed082e78d4b580ff260a677508718f88016333e2c9c5", + "sha256:e7514d7a48c063226b7d06617cbb12a14278d4323a065a8d46a7962686ce2e95", + "sha256:f07909cd9fc08c52d294b1570bba92186181ca01fe3dc9ffba68955273dd7362" + ], + "markers": "sys_platform != 'win32' and sys_platform != 'cygwin' and platform_python_implementation != 'PyPy'", + "version": "==0.14.0" + }, + "websockets": { + "hashes": [ + "sha256:0e4fb4de42701340bd2353bb2eee45314651caa6ccee80dbd5f5d5978888fed5", + "sha256:1d3f1bf059d04a4e0eb4985a887d49195e15ebabc42364f4eb564b1d065793f5", + "sha256:20891f0dddade307ffddf593c733a3fdb6b83e6f9eef85908113e628fa5a8308", + "sha256:295359a2cc78736737dd88c343cd0747546b2174b5e1adc223824bcaf3e164cb", + "sha256:2db62a9142e88535038a6bcfea70ef9447696ea77891aebb730a333a51ed559a", + "sha256:3762791ab8b38948f0c4d281c8b2ddfa99b7e510e46bd8dfa942a5fff621068c", + "sha256:3db87421956f1b0779a7564915875ba774295cc86e81bc671631379371af1170", + "sha256:3ef56fcc7b1ff90de46ccd5a687bbd13a3180132268c4254fc0fa44ecf4fc422", + "sha256:4f9f7d28ce1d8f1295717c2c25b732c2bc0645db3215cf757551c392177d7cb8", + "sha256:5c01fd846263a75bc8a2b9542606927cfad57e7282965d96b93c387622487485", + "sha256:5c65d2da8c6bce0fca2528f69f44b2f977e06954c8512a952222cea50dad430f", + "sha256:751a556205d8245ff94aeef23546a1113b1dd4f6e4d102ded66c39b99c2ce6c8", + "sha256:7ff46d441db78241f4c6c27b3868c9ae71473fe03341340d2dfdbe8d79310acc", + "sha256:965889d9f0e2a75edd81a07592d0ced54daa5b0785f57dc429c378edbcffe779", + "sha256:9b248ba3dd8a03b1a10b19efe7d4f7fa41d158fdaa95e2cf65af5a7b95a4f989", + "sha256:9bef37ee224e104a413f0780e29adb3e514a5b698aabe0d969a6ba426b8435d1", + "sha256:c1ec8db4fac31850286b7cd3b9c0e1b944204668b8eb721674916d4e28744092", + "sha256:c8a116feafdb1f84607cb3b14aa1418424ae71fee131642fc568d21423b51824", + "sha256:ce85b06a10fc65e6143518b96d3dca27b081a740bae261c2fb20375801a9d56d", + "sha256:d705f8aeecdf3262379644e4b55107a3b55860eb812b673b28d0fbc347a60c55", + "sha256:e898a0863421650f0bebac8ba40840fc02258ef4714cb7e1fd76b6a6354bda36", + "sha256:f8a7bff6e8664afc4e6c28b983845c5bc14965030e3fb98789734d416af77c4b" + ], + "version": "==8.1" + }, "werkzeug": { "hashes": [ "sha256:169ba8a33788476292d04186ab33b01d6add475033dfc07215e6d219cc077096", @@ -211,14 +320,6 @@ ], "version": "==3.1.0" }, - "importlib-metadata": { - "hashes": [ - "sha256:06f5b3a99029c7134207dd882428a66992a9de2bef7c2b699b5641f9886c3302", - "sha256:b97607a1a18a5100839aec1dc26a1ea17ee0d93b20b0f008d80a5a050afb200b" - ], - "markers": "python_version < '3.8'", - "version": "==1.5.0" - }, "isort": { "hashes": [ "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1", @@ -319,19 +420,19 @@ }, "pyyaml": { "hashes": [ - "sha256:059b2ee3194d718896c0ad077dd8c043e5e909d9180f387ce42012662a4946d6", - "sha256:1cf708e2ac57f3aabc87405f04b86354f66799c8e62c28c5fc5f88b5521b2dbf", - "sha256:24521fa2890642614558b492b473bee0ac1f8057a7263156b02e8b14c88ce6f5", - "sha256:4fee71aa5bc6ed9d5f116327c04273e25ae31a3020386916905767ec4fc5317e", - "sha256:70024e02197337533eef7b85b068212420f950319cc8c580261963aefc75f811", - "sha256:74782fbd4d4f87ff04159e986886931456a1894c61229be9eaf4de6f6e44b99e", - "sha256:940532b111b1952befd7db542c370887a8611660d2b9becff75d39355303d82d", - "sha256:cb1f2f5e426dc9f07a7681419fe39cee823bb74f723f36f70399123f439e9b20", - "sha256:dbbb2379c19ed6042e8f11f2a2c66d39cceb8aeace421bfc29d085d93eda3689", - "sha256:e3a057b7a64f1222b56e47bcff5e4b94c4f61faac04c7c4ecb1985e18caa3994", - "sha256:e9f45bd5b92c7974e59bcd2dcc8631a6b6cc380a904725fce7bc08872e691615" + "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97", + "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76", + "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2", + "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648", + "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf", + "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f", + "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2", + "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee", + "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d", + "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c", + "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a" ], - "version": "==5.3" + "version": "==5.3.1" }, "six": { "hashes": [ @@ -354,52 +455,18 @@ ], "version": "==1.32.0" }, - "typed-ast": { - "hashes": [ - "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355", - "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919", - "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa", - "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652", - "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75", - "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01", - "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d", - "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1", - "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907", - "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c", - "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3", - "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b", - "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614", - "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb", - "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b", - "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41", - "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6", - "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34", - "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe", - "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4", - "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7" - ], - "markers": "implementation_name == 'cpython' and python_version < '3.8'", - "version": "==1.4.1" - }, "wcwidth": { "hashes": [ - "sha256:8fd29383f539be45b20bd4df0dc29c20ba48654a41e661925e612311e9f3c603", - "sha256:f28b3e8a6483e5d49e7f8949ac1a78314e740333ae305b4ba5defd3e74fb37a8" + "sha256:cafe2186b3c009a04067022ce1dcd79cb38d8d65ee4f4791b8888d6599d1bbe1", + "sha256:ee73862862a156bf77ff92b09034fc4825dd3af9cf81bc5b360668d425f3c5f1" ], - "version": "==0.1.8" + "version": "==0.1.9" }, "wrapt": { "hashes": [ "sha256:565a021fd19419476b9362b05eeaa094178de64f8361e44468f9e9d7843901e1" ], "version": "==1.11.2" - }, - "zipp": { - "hashes": [ - "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b", - "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96" - ], - "version": "==3.1.0" } } } diff --git a/Procfile b/Procfile index a46b58e3..dd838293 100644 --- a/Procfile +++ b/Procfile @@ -1 +1 @@ -web: gunicorn app:create_app\(\) \ No newline at end of file +web: gunicorn app.main:APP -k uvicorn.workers.UvicornWorker \ No newline at end of file diff --git a/README.md b/README.md index 840efc72..ece56e07 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,13 @@ You can open the URL in your browser to further inspect the response. Or you can curl https://coronavirus-tracker-api.herokuapp.com/v2/locations | json_pp ``` +### Swagger/OpenAPI + +Consume our API through [an interactive SwaggerUI](https://coronavirus-tracker-api.herokuapp.com/) (on mobile, use the [mobile friendly ReDocs](https://coronavirus-tracker-api.herokuapp.com/docs) instead for the best experience). + + +The [OpenAPI](https://swagger.io/docs/specification/about/) json definition can be downloaded at https://coronavirus-tracker-api.herokuapp.com/openapi.json + ## API Endpoints ### Sources Endpoint @@ -122,6 +129,7 @@ __Sample response__ "country": "Norway", "country_code": "NO", "province": "", + "county": "", "last_updated": "2020-03-21T06:59:11.315422Z", "coordinates": { }, "latest": { }, @@ -166,6 +174,7 @@ __Sample response__ "country": "Thailand", "country_code": "TH", "province": "", + "county": "", "last_updated": "2020-03-21T06:59:11.315422Z", "coordinates": { "latitude": "15", @@ -182,6 +191,7 @@ __Sample response__ "country": "Norway", "country_code": "NO", "province": "", + "county": "", "last_updated": "2020-03-21T06:59:11.315422Z", "coordinates": { "latitude": "60.472", @@ -231,29 +241,30 @@ GET /v2/locations?country_code=IT __Sample Response__ ```json { - "latest": { - "confirmed": 59138, - "deaths": 5476, - "recovered": 7024 - }, - "locations": [ - { - "coordinates": { - "latitude": "43", - "longitude": "12" - }, - "country": "Italy", - "country_code": "IT", - "id": 16, - "last_updated": "2020-03-23T13:32:23.913872Z", - "latest": { - "confirmed": 59138, - "deaths": 5476, - "recovered": 7024 - }, - "province": "" - } - ] + "latest": { + "confirmed": 59138, + "deaths": 5476, + "recovered": 7024 + }, + "locations": [ + { + "id": 16, + "country": "Italy", + "country_code": "IT", + "province": "", + "county": "", + "last_updated": "2020-03-23T13:32:23.913872Z", + "coordinates": { + "latitude": "43", + "longitude": "12" + }, + "latest": { + "confirmed": 59138, + "deaths": 5476, + "recovered": 7024 + } + } + ] } ``` @@ -385,7 +396,7 @@ You will need the following things properly installed on your computer. ## Running / Development -* `flask run` +* `pipenv run dev` * Visit your app at [http://localhost:5000](http://localhost:5000). ### Running Tests diff --git a/app/__init__.py b/app/__init__.py index 9861d8b9..b4b15379 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,27 +1,4 @@ -from flask import Flask -from flask_cors import CORS - # See PEP396. __version__ = '2.0' -def create_app(): - """ - Construct the core application. - """ - # Create flask app with CORS enabled. - app = Flask(__name__) - CORS(app) - - # Set app config from settings. - app.config.from_pyfile('config/settings.py'); - - with app.app_context(): - # Import routes. - from . import routes - - # Register api endpoints. - app.register_blueprint(routes.api_v1) - app.register_blueprint(routes.api_v2) - - # Return created app. - return app +from .core import create_app diff --git a/app/core.py b/app/core.py new file mode 100644 index 00000000..d631deb4 --- /dev/null +++ b/app/core.py @@ -0,0 +1,23 @@ +from flask import Flask +from flask_cors import CORS + +def create_app(): + """ + Construct the core application. + """ + # Create flask app with CORS enabled. + app = Flask(__name__) + CORS(app) + + # Set app config from settings. + app.config.from_pyfile('config/settings.py'); + + with app.app_context(): + # Import routes. + from . import routes + + # Register api endpoints. + app.register_blueprint(routes.api_v1) + + # Return created app. + return app diff --git a/app/main.py b/app/main.py new file mode 100644 index 00000000..076aa551 --- /dev/null +++ b/app/main.py @@ -0,0 +1,186 @@ +""" +app.main.py +""" +import datetime as dt +import enum +import logging +import os +import reprlib +from typing import Dict, List + +import fastapi +import pydantic +import uvicorn +from fastapi.middleware.wsgi import WSGIMiddleware + +from . import models +from .core import create_app +from .data import data_source, data_sources + +# ################ +# Dependencies +# ################ + + +class Sources(str, enum.Enum): + """ + A source available for retrieving data. + """ + jhu = 'jhu' + csbs = 'csbs' + + +# ############ +# FastAPI App +# ############ +LOGGER = logging.getLogger('api') + +APP = fastapi.FastAPI( + title='Coronavirus Tracker', + description='API for tracking the global coronavirus (COVID-19, SARS-CoV-2) outbreak. Project page: https://github.com/ExpDev07/coronavirus-tracker-api.', + version='2.0.1', + docs_url='/', + redoc_url='/docs', +) + +# ##################### +# Middleware +####################### + + +# TODO this could probably just be a FastAPI dependency. +@APP.middleware('http') +async def add_datasource(request: fastapi.Request, call_next): + """ + Attach the data source to the request.state. + """ + # Retrieve the datas ource from query param. + source = data_source(request.query_params.get('source', default='jhu')) + + # Abort with 404 if source cannot be found. + if not source: + return fastapi.Response('The provided data-source was not found.', status_code=404) + + # Attach source to request. + request.state.source = source + + # Move on... + LOGGER.info(f'source provided: {source.__class__.__name__}') + response = await call_next(request) + return response + + +# ################ +# Exception Handler +# ################ + + +@APP.exception_handler(pydantic.error_wrappers.ValidationError) +async def handle_validation_error( + request: fastapi.Request, exc: pydantic.error_wrappers.ValidationError +): + """ + Handles validation errors. + """ + return fastapi.responses.JSONResponse({'message': exc.errors()}, status_code=422) + + +# ################ +# Routes +# ################ + +V2 = fastapi.APIRouter() + + +@V2.get('/latest', response_model=models.LatestResponse) +def get_latest(request: fastapi.Request, source: Sources = 'jhu'): + """ + Getting latest amount of total confirmed cases, deaths, and recoveries. + """ + locations = request.state.source.get_all() + return { + 'latest': { + 'confirmed': sum(map(lambda location: location.confirmed, locations)), + 'deaths' : sum(map(lambda location: location.deaths, locations)), + 'recovered': sum(map(lambda location: location.recovered, locations)), + } + } + + +@V2.get( + '/locations', response_model=models.LocationsResponse, response_model_exclude_unset=True +) +def get_locations( + request: fastapi.Request, + source: Sources = 'jhu', + country_code: str = None, + province: str = None, + county: str = None, + timelines: bool = False, +): + """ + Getting the locations. + """ + # All query paramameters. + params = dict(request.query_params) + + # Retrieve all the locations. + locations = request.state.source.get_all() + + # Attempt to filter out locations with properties matching the provided query params. + for key, value in params.items(): + # Clean keys for security purposes. + key = key.lower() + value = value.lower().strip('__') + + # Do filtering. + try: + locations = [location for location in locations if str(getattr(location, key)).lower() == str(value)] + except AttributeError: + pass + + # Return final serialized data. + return { + 'latest': { + 'confirmed': sum(map(lambda location: location.confirmed, locations)), + 'deaths' : sum(map(lambda location: location.deaths, locations)), + 'recovered': sum(map(lambda location: location.recovered, locations)), + }, + 'locations': [location.serialize(timelines) for location in locations], + } + + +@V2.get('/locations/{id}', response_model=models.LocationResponse) +def get_location_by_id(request: fastapi.Request, id: int, source: Sources = 'jhu', timelines: bool = True): + """ + Getting specific location by id. + """ + return { + 'location': request.state.source.get(id).serialize(timelines) + } + + +@V2.get('/sources') +async def sources(): + """ + Retrieves a list of data-sources that are availble to use. + """ + return { + 'sources': list(data_sources.keys()) + } + +# Include routers. +APP.include_router(V2, prefix='/v2', tags=['v2']) + +# mount the existing Flask app +# v1 @ / +APP.mount('/', WSGIMiddleware(create_app())) + +# Running of app. +if __name__ == '__main__': + uvicorn.run( + 'app.main:APP', + host='127.0.0.1', + port=int(os.getenv('PORT', 5000)), + log_level='info', + ) diff --git a/app/models.py b/app/models.py new file mode 100644 index 00000000..05dcaa9c --- /dev/null +++ b/app/models.py @@ -0,0 +1,48 @@ +""" +app.models.py +~~~~~~~~~~~~~ +Reponse data models. +""" +from pydantic import BaseModel +from typing import Dict, List + +class Latest(BaseModel): + confirmed: int + deaths: int + recovered: int + + +class LatestResponse(BaseModel): + latest: Latest + + +class Timeline(BaseModel): + latest: int + timeline: Dict[str, int] = {} + + +class Timelines(BaseModel): + confirmed: Timeline + deaths: Timeline + recovered: Timeline + + +class Location(BaseModel): + id: int + country: str + country_code: str + county: str = '' + province: str = '' + last_updated: str # TODO use datetime.datetime type. + coordinates: Dict + latest: Latest + timelines: Timelines = {} + + +class LocationsResponse(BaseModel): + latest: Latest + locations: List[Location] = [] + + +class LocationResponse(BaseModel): + location: Location diff --git a/app/routes/__init__.py b/app/routes/__init__.py index 8d1f45eb..ebc9eff5 100644 --- a/app/routes/__init__.py +++ b/app/routes/__init__.py @@ -3,10 +3,6 @@ # Follow the import order to avoid circular dependency api_v1 = Blueprint('api_v1', __name__, url_prefix='') -api_v2 = Blueprint('api_v2', __name__, url_prefix='/v2') - -# API version 2. -from .v2 import locations, latest, sources # API version 1. from .v1 import confirmed, deaths, recovered, all @@ -15,20 +11,3 @@ @app.route('/') def index(): return redirect('https://github.com/ExpDev07/coronavirus-tracker-api', 302) - -# Middleware for picking data source. -@api_v2.before_request -def datasource(): - """ - Attaches the datasource to the request. - """ - # Retrieve the datas ource from query param. - source = data_source(request.args.get('source', type=str, default='jhu')) - - # Abort with 404 if source cannot be found. - if not source: - return abort(404, description='The provided data-source was not found.') - - # Attach source to request and return it. - request.source = source - pass diff --git a/app/routes/v2/__init__.py b/app/routes/v2/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/app/routes/v2/latest.py b/app/routes/v2/latest.py deleted file mode 100644 index 431bb8cd..00000000 --- a/app/routes/v2/latest.py +++ /dev/null @@ -1,18 +0,0 @@ -from flask import request, jsonify -from ...routes import api_v2 as api - -@api.route('/latest') -def latest(): - # Get the serialized version of all the locations. - locations = request.source.get_all() - - # All the latest information. - # latest = list(map(lambda location: location['latest'], locations)) - - return jsonify({ - 'latest': { - 'confirmed': sum(map(lambda location: location.confirmed, locations)), - 'deaths' : sum(map(lambda location: location.deaths, locations)), - 'recovered': sum(map(lambda location: location.recovered, locations)), - } - }) diff --git a/app/routes/v2/locations.py b/app/routes/v2/locations.py deleted file mode 100644 index 5a222b90..00000000 --- a/app/routes/v2/locations.py +++ /dev/null @@ -1,42 +0,0 @@ -from flask import jsonify, request -from distutils.util import strtobool -from ...routes import api_v2 as api - -@api.route('/locations') -def locations(): - # Query parameters. - args = request.args - timelines = strtobool(args.get('timelines', default='0')) - - # Retrieve all the locations. - locations = request.source.get_all() - - # Filtering by args if provided. - for i in args: - if i != 'timelines' and i[:2] != '__': - try: - locations = [j for j in locations if getattr(j, i) == args.get(i, type=str)] - except AttributeError: - print('Location does not have attribute {}.'.format(i)) - - # Serialize each location and return. - return jsonify({ - 'latest': { - 'confirmed': sum(map(lambda location: location.confirmed, locations)), - 'deaths' : sum(map(lambda location: location.deaths, locations)), - 'recovered': sum(map(lambda location: location.recovered, locations)), - }, - 'locations': [ - location.serialize(timelines) for location in locations - ] - }) - -@api.route('/locations/') -def location(id): - # Query parameters. - timelines = strtobool(request.args.get('timelines', default='1')) - - # Return serialized location. - return jsonify({ - 'location': request.source.get(id).serialize(timelines) - }) diff --git a/app/routes/v2/sources.py b/app/routes/v2/sources.py deleted file mode 100644 index 749e3b70..00000000 --- a/app/routes/v2/sources.py +++ /dev/null @@ -1,12 +0,0 @@ -from flask import jsonify -from ...data import data_sources -from ...routes import api_v2 as api - -@api.route('/sources') -def sources(): - """ - Retrieves a list of data-sources that are availble to use. - """ - return jsonify({ - 'sources': list(data_sources.keys()) - }) diff --git a/app/services/location/jhu.py b/app/services/location/jhu.py index 0a42c9d9..885c658e 100644 --- a/app/services/location/jhu.py +++ b/app/services/location/jhu.py @@ -49,8 +49,6 @@ def get_category(category): if category == 'recovered': url = base_url + 'time_series_19-covid-Recovered.csv' - print (url) - # Request the data request = requests.get(url) text = request.text diff --git a/coronavirus-tracker-api-swagger.yaml b/coronavirus-tracker-api-swagger.yaml deleted file mode 100644 index d17b9d42..00000000 --- a/coronavirus-tracker-api-swagger.yaml +++ /dev/null @@ -1,152 +0,0 @@ -swagger: '2.0' -info: - description: 'Corona Virus Tracker API based on JHU data sets. https://github.com/ExpDev07/coronavirus-tracker-api' - version: 1.0.0 - title: Corona Virus Tracker API - contact: - url: https://github.com/ExpDev07/coronavirus-tracker-api -host: coronavirus-tracker-api.herokuapp.com -basePath: /v2 -schemes: - - https -paths: - /latest: - get: - summary: Latest global Corona stats - operationId: latest - produces: - - application/json - responses: - '200': - description: successful operation - schema: - type: object - properties: - latest: - type: object - properties: - confirmed: - type: number - example: 214910 - deaths: - type: number - example: 8733 - recovered: - type: number - example: 83207 - /locations: - get: - summary: All Locations - operationId: allLocations - produces: - - application/json - parameters: - - name: country_code - in: query - description: "ISO alpha 2 country code. https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2" - required: false - type: string - x-example: US - - name: timelines - in: query - description: "Include timeline objects in response? true or false" - required: false - type: string - x-example: true - responses: - '200': - description: successful operation - schema: - type: object - properties: - locations: - type: array - items: - $ref: '#/definitions/Location' - '/locations/{id}': - get: - summary: location - operationId: location - produces: - - application/json - parameters: - - name: id - in: path - description: "Internal ID" - required: true - type: string - x-example: 39 - responses: - '200': - description: successful operation - schema: - type: object - properties: - location: - $ref: '#/definitions/Location' - '404': - description: Location not found -definitions: - Location: - type: object - properties: - coordinates: - type: object - properties: - latitude: - type: string - example: "47.4009" - longitude: - type: string - example: "-121.4905" - country: - type: string - example: "US" - country_code: - type: string - example: US - id: - type: number - example: 98 - latest: - type: object - properties: - confirmed: - type: number - example: 1014 - deaths: - type: number - example: 55 - recovered: - type: number - example: 10 - province: - type: string - example: "Washington" - timelines: - type: object - properties: - confirmed: - type: object - properties: - latest: - type: number - example: 1014 - timeline: - type: object - deaths: - type: object - properties: - latest: - type: number - example: 55 - timeline: - type: object - recovered: - type: object - properties: - latest: - type: number - example: 10 - timeline: - type: object diff --git a/runtime.txt b/runtime.txt new file mode 100644 index 00000000..724c203e --- /dev/null +++ b/runtime.txt @@ -0,0 +1 @@ +python-3.8.2 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..b1271106 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,18 @@ +""" +tests.conftest.py + +Global conftest file for shared pytest fixtures +""" +import pytest +from fastapi.testclient import TestClient + + +from app.main import APP + +@pytest.fixture +def api_client(): + """ + Returns a TestClient. + The test client uses the requests library for making http requests. + """ + return TestClient(APP) diff --git a/tests/test_routes.py b/tests/test_routes.py index e08eb349..dee93465 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -1,8 +1,12 @@ import app import unittest +from fastapi.testclient import TestClient import json from unittest import mock +from pprint import pformat as pf +import pytest from app import services +from app.main import APP from .test_jhu import mocked_requests_get, mocked_strptime_isoformat, DATETIME_STRING @@ -20,6 +24,7 @@ class FlaskRoutesTest(unittest.TestCase): def setUp(self): self.client = FlaskRoutesTest.app.test_client() + self.asgi_client = TestClient(APP) self.date = DATETIME_STRING def read_file_v1(self, state): @@ -29,15 +34,11 @@ def read_file_v1(self, state): return expected_json_output def test_root_api(self, mock_request_get, mock_datetime): - """Validate redirections of /""" - mock_datetime.utcnow.return_value.isoformat.return_value = self.date - mock_datetime.strptime.side_effect = mocked_strptime_isoformat - return_data = self.client.get("/") - - assert return_data.status_code == 302 + """Validate that / returns a 200 and is not a redirect.""" + response = self.asgi_client.get("/") - assert dict(return_data.headers)["Location"] == \ - "https://github.com/ExpDev07/coronavirus-tracker-api" + assert response.status_code == 200 + assert not response.is_redirect def test_v1_confirmed(self, mock_request_get, mock_datetime): mock_datetime.utcnow.return_value.isoformat.return_value = self.date @@ -79,8 +80,7 @@ def test_v2_latest(self, mock_request_get, mock_datetime): mock_datetime.utcnow.return_value.isoformat.return_value = DATETIME_STRING mock_datetime.strptime.side_effect = mocked_strptime_isoformat state = "latest" - return_data = self.client.get("/v2/{}".format(state)).data.decode() - return_data = json.loads(return_data) + return_data = self.asgi_client.get(f"/v2/{state}").json() check_dict = { 'latest': { @@ -96,13 +96,13 @@ def test_v2_locations(self, mock_request_get, mock_datetime): mock_datetime.utcnow.return_value.isoformat.return_value = DATETIME_STRING mock_datetime.strptime.side_effect = mocked_strptime_isoformat state = "locations" - return_data = self.client.get("/v2/{}".format(state)).data.decode() + return_data = self.asgi_client.get("/v2/{}".format(state)).json() filepath = "tests/expected_output/v2_{state}.json".format(state=state) with open(filepath, "r") as file: expected_json_output = file.read() - #assert return_data == expected_json_output + # assert return_data == json.loads(expected_json_output) def test_v2_locations_id(self, mock_request_get, mock_datetime): mock_datetime.utcnow.return_value.isoformat.return_value = DATETIME_STRING @@ -110,7 +110,7 @@ def test_v2_locations_id(self, mock_request_get, mock_datetime): state = "locations" test_id = 1 - return_data = self.client.get("/v2/{}/{}".format(state, test_id)).data.decode() + return_data = self.asgi_client.get("/v2/{}/{}".format(state, test_id)).json() filepath = "tests/expected_output/v2_{state}_id_{test_id}.json".format(state=state, test_id=test_id) with open(filepath, "r") as file: @@ -120,3 +120,43 @@ def test_v2_locations_id(self, mock_request_get, mock_datetime): def tearDown(self): pass + + +@pytest.mark.parametrize( + "query_params", + [ + {"source": "csbs"}, + {"source": "jhu"}, + {"timelines": True}, + {"timelines": "true"}, + {"timelines": 1}, + {"source": "jhu", "timelines": True}, + ], +) +def test_locations_status_code(api_client, query_params): + response = api_client.get("/v2/locations", params=query_params) + print(f"GET {response.url}\n{response}") + assert response.status_code == 200 + + +@pytest.mark.parametrize( + "query_params", + [ + {"source": "csbs"}, + {"source": "jhu"}, + {"timelines": True}, + {"timelines": "true"}, + {"timelines": 1}, + {"source": "jhu", "timelines": True}, + ], +) +def test_latest(api_client, query_params): + response = api_client.get("/v2/latest", params=query_params) + print(f"GET {response.url}\n{response}") + + response_json = response.json() + print(f"\tjson:\n{pf(response_json)}") + + assert response.status_code == 200 + assert response_json["latest"]["confirmed"] + assert response_json["latest"]["deaths"] diff --git a/tests/test_swagger.py b/tests/test_swagger.py new file mode 100644 index 00000000..3d71ae64 --- /dev/null +++ b/tests/test_swagger.py @@ -0,0 +1,9 @@ + +import pytest + +@pytest.mark.parametrize("route",["/", "/docs", "/openapi.json"]) +def test_swagger(api_client, route): + """Test that the swagger ui, redoc and openapi json are available.""" + response = api_client.get(route) + print(f"GET {route} {response}\n\n{response.content}") + assert response.status_code == 200