From 5f1a422ac1f5348349d3aff09729521844ebf368 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sat, 21 Mar 2020 19:15:43 -0400 Subject: [PATCH 01/49] add FastAPI uvicorn --- Pipfile | 6 ++ Pipfile.lock | 160 +++++++++++++++++++++++++++++++++------------------ 2 files changed, 111 insertions(+), 55 deletions(-) diff --git a/Pipfile b/Pipfile index 104c27c8..297fb5cb 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_app = "uvicorn app.main:APP --reload" +app = "uvicorn app.main:APP" \ No newline at end of file diff --git a/Pipfile.lock b/Pipfile.lock index bcc795ad..d505e349 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,13 @@ "index": "pypi", "version": "==20.0.4" }, + "h11": { + "hashes": [ + "sha256:33d4bca7be0fa039f4e84d50ab00531047e53d6ee8ffbc83501ea602c169cae1", + "sha256:4bc6d6a1238b7615b266ada57e0618568066f57dd6fa967d1290ec9309b2f2f1" + ], + "version": "==0.9.0" + }, "idna": { "hashes": [ "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb", @@ -128,6 +143,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 +193,13 @@ ], "version": "==1.14.0" }, + "starlette": { + "hashes": [ + "sha256:6169ee78ded501095d1dda7b141a1dc9f9934d37ad23196e180150ace2c6449b", + "sha256:a9bb130fa7aa736eda8a814b6ceb85ccf7a209ed53843d0d61e246b380afa10f" + ], + "version": "==0.13.2" + }, "urllib3": { "hashes": [ "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc", @@ -166,6 +207,41 @@ ], "version": "==1.25.8" }, + "uvicorn": { + "hashes": [ + "sha256:0f58170165c4495f563d8224b2f415a0829af0412baa034d6f777904613087fd", + "sha256:6fdaf8e53bf1b2ddf0fe9ed06079b5348d7d1d87b3365fe2549e6de0d49e631c" + ], + "index": "pypi", + "version": "==0.11.3" + }, + "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", @@ -182,6 +258,14 @@ ], "version": "==2.3.3" }, + "atomicwrites": { + "hashes": [ + "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4", + "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6" + ], + "markers": "sys_platform == 'win32'", + "version": "==1.3.0" + }, "attrs": { "hashes": [ "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", @@ -197,6 +281,14 @@ "index": "pypi", "version": "==1.6.2" }, + "colorama": { + "hashes": [ + "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff", + "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1" + ], + "markers": "sys_platform == 'win32'", + "version": "==0.4.3" + }, "gitdb": { "hashes": [ "sha256:284a6a4554f954d6e737cddcff946404393e030b76a282c6640df8efd6b3da5e", @@ -211,14 +303,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 +403,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,33 +438,6 @@ ], "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", @@ -393,13 +450,6 @@ "sha256:565a021fd19419476b9362b05eeaa094178de64f8361e44468f9e9d7843901e1" ], "version": "==1.11.2" - }, - "zipp": { - "hashes": [ - "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b", - "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96" - ], - "version": "==3.1.0" } } } From 8166a4d07d0e79772340b9304ade1ee709e2b5fd Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sat, 21 Mar 2020 19:16:43 -0400 Subject: [PATCH 02/49] define app --- app/main.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 app/main.py diff --git a/app/main.py b/app/main.py new file mode 100644 index 00000000..44c45b89 --- /dev/null +++ b/app/main.py @@ -0,0 +1,25 @@ +""" +app.main.py +""" +import os + +import fastapi +import uvicorn + +APP = fastapi.FastAPI( + title="Coronavirus Tracker", + description="API for tracking the global coronavirus (COVID-19, SARS-CoV-2) outbreak.", + version="3.0.0", + prefix="/v3", + docs_url="/v3", + redoc_url="/docs", +) + + +if __name__ == "__main__": + uvicorn.run( + "app.main:APP", + host="127.0.0.1", + port=int(os.getenv("PORT", 5000)), + log_level="info", + ) From d79965ebf624966901b910d61324d7231b9fce00 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sat, 21 Mar 2020 20:15:02 -0400 Subject: [PATCH 03/49] create provisional models --- app/main.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/app/main.py b/app/main.py index 44c45b89..40247584 100644 --- a/app/main.py +++ b/app/main.py @@ -2,10 +2,35 @@ app.main.py """ import os +import datetime as dt +from typing import Dict import fastapi +import pydantic import uvicorn + +class Stats(pydantic.BaseModel): + confirmed: int + deaths: int + recovered: int + + +class Latest(pydantic.BaseModel): + latest: Stats + + +class Country(pydantic.BaseModel): + id: int + country: str + country_code: str + province: str = None + last_updated: dt.datetime = None + coordinates: Dict = None + latest: Stats = None + timelines: Dict = None + + APP = fastapi.FastAPI( title="Coronavirus Tracker", description="API for tracking the global coronavirus (COVID-19, SARS-CoV-2) outbreak.", From cd64e471864e07ccb6565201d2b273ac033371cd Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sat, 21 Mar 2020 20:15:41 -0400 Subject: [PATCH 04/49] define latest, location endpoints --- app/main.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/app/main.py b/app/main.py index 40247584..8b165b1a 100644 --- a/app/main.py +++ b/app/main.py @@ -41,6 +41,22 @@ class Country(pydantic.BaseModel): ) +@APP.get("/latest", response_model=Latest) +def get_latest(): + sample_data = {"latest": {"confirmed": 197146, "deaths": 7905, "recovered": 80840}} + return sample_data + + +@APP.get("/locations") +def get_all_locations(country_code: str = None, timelines: int = None): + return + + +@APP.get("/locations/{id}") +def get_location_by_id(id: int): + return + + if __name__ == "__main__": uvicorn.run( "app.main:APP", From 21f4472b526216a1956d26d8f11b2e79a4292345 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sun, 22 Mar 2020 09:19:54 -0400 Subject: [PATCH 05/49] update models --- app/main.py | 36 ++++++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/app/main.py b/app/main.py index 8b165b1a..0c7348e6 100644 --- a/app/main.py +++ b/app/main.py @@ -3,32 +3,52 @@ """ import os import datetime as dt -from typing import Dict +from typing import Dict, List import fastapi import pydantic import uvicorn -class Stats(pydantic.BaseModel): +class Totals(pydantic.BaseModel): confirmed: int deaths: int recovered: int class Latest(pydantic.BaseModel): - latest: Stats + latest: Totals + + +class TimelineStats(pydantic.BaseModel): + latest: int + timeline: Dict[str, int] + + +class Timelines(pydantic.BaseModel): + confirmed: TimelineStats + deaths: TimelineStats + recovered: TimelineStats class Country(pydantic.BaseModel): - id: int + coordinates: Dict = None country: str country_code: str - province: str = None + id: int last_updated: dt.datetime = None - coordinates: Dict = None - latest: Stats = None - timelines: Dict = None + latest: Totals = None + province: str = None + timelines: Timelines = None + + +class AllLocations(pydantic.BaseModel): + latest: Totals + locations: List[Country] + + +class Location(pydantic.BaseModel): + location: Country APP = fastapi.FastAPI( From b36275052fa363c53294d89cba71065ffb4ecec6 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sun, 22 Mar 2020 09:36:33 -0400 Subject: [PATCH 06/49] change model name to match pre-existing model --- app/main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/main.py b/app/main.py index 0c7348e6..da3d4f14 100644 --- a/app/main.py +++ b/app/main.py @@ -25,7 +25,7 @@ class TimelineStats(pydantic.BaseModel): timeline: Dict[str, int] -class Timelines(pydantic.BaseModel): +class TimelinedLocation(pydantic.BaseModel): confirmed: TimelineStats deaths: TimelineStats recovered: TimelineStats @@ -39,7 +39,7 @@ class Country(pydantic.BaseModel): last_updated: dt.datetime = None latest: Totals = None province: str = None - timelines: Timelines = None + timelines: TimelinedLocation = None class AllLocations(pydantic.BaseModel): From 033d9247a0953c14b5758b75b7577fa9e8b29a95 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sun, 22 Mar 2020 09:48:28 -0400 Subject: [PATCH 07/49] defaults --- app/main.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/main.py b/app/main.py index da3d4f14..82eb6a97 100644 --- a/app/main.py +++ b/app/main.py @@ -32,14 +32,14 @@ class TimelinedLocation(pydantic.BaseModel): class Country(pydantic.BaseModel): - coordinates: Dict = None + coordinates: Dict country: str country_code: str id: int - last_updated: dt.datetime = None - latest: Totals = None - province: str = None - timelines: TimelinedLocation = None + last_updated: dt.datetime + latest: Totals + province: str = "" + timelines: TimelinedLocation class AllLocations(pydantic.BaseModel): From b1a4742d03ea8b57238ad8f0e4fd0b7355c380f7 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sun, 22 Mar 2020 09:48:42 -0400 Subject: [PATCH 08/49] update versions and response models --- app/main.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/main.py b/app/main.py index 82eb6a97..00ff8dcb 100644 --- a/app/main.py +++ b/app/main.py @@ -54,25 +54,26 @@ class Location(pydantic.BaseModel): APP = fastapi.FastAPI( title="Coronavirus Tracker", description="API for tracking the global coronavirus (COVID-19, SARS-CoV-2) outbreak.", - version="3.0.0", - prefix="/v3", - docs_url="/v3", + version="2.1.0", + prefix="/v2-1", + docs_url="/v2-1", redoc_url="/docs", ) @APP.get("/latest", response_model=Latest) def get_latest(): + """Getting latest amount of total confirmed cases, deaths, and recoveries.""" sample_data = {"latest": {"confirmed": 197146, "deaths": 7905, "recovered": 80840}} return sample_data -@APP.get("/locations") +@APP.get("/locations", response_model=AllLocations) def get_all_locations(country_code: str = None, timelines: int = None): return -@APP.get("/locations/{id}") +@APP.get("/locations/{id}", response_model=Location) def get_location_by_id(id: int): return From bdae7f098fa83631b947cd3537c7276dca26cc91 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sun, 22 Mar 2020 12:58:48 -0400 Subject: [PATCH 09/49] divide sections --- app/main.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/app/main.py b/app/main.py index 00ff8dcb..f284d159 100644 --- a/app/main.py +++ b/app/main.py @@ -10,6 +10,11 @@ import uvicorn +# ################################# +# Models +# ################################# + + class Totals(pydantic.BaseModel): confirmed: int deaths: int @@ -51,6 +56,16 @@ class Location(pydantic.BaseModel): location: Country +# ################ +# Dependencies +# ################ + + +# ############ +# 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.", @@ -60,6 +75,15 @@ class Location(pydantic.BaseModel): redoc_url="/docs", ) +# ##################### +# Middleware +####################### + + +# ################ +# Routes +# ################ + @APP.get("/latest", response_model=Latest) def get_latest(): From 825de314b859c773f7e9bb37c956d41796dee42f Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sun, 22 Mar 2020 13:15:59 -0400 Subject: [PATCH 10/49] create middleware to attach the "data_source" https://www.starlette.io/requests/#other-state --- app/main.py | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/app/main.py b/app/main.py index f284d159..409304e1 100644 --- a/app/main.py +++ b/app/main.py @@ -2,6 +2,7 @@ app.main.py """ import os +import logging import datetime as dt from typing import Dict, List @@ -9,6 +10,7 @@ import pydantic import uvicorn +from .data import data_source # ################################# # Models @@ -80,16 +82,33 @@ class Location(pydantic.BaseModel): ####################### +# 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.""" + source = request.query_params.get("source", default="jhu") + request.state.source = data_source(source) + LOGGER.info(f"source: {request.state.source.__class__.__name__}") + response = await call_next(request) + return response + + # ################ # Routes # ################ @APP.get("/latest", response_model=Latest) -def get_latest(): +def get_latest(request: fastapi.Request): """Getting latest amount of total confirmed cases, deaths, and recoveries.""" - sample_data = {"latest": {"confirmed": 197146, "deaths": 7905, "recovered": 80840}} - return sample_data + 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)), + } + } @APP.get("/locations", response_model=AllLocations) From e31aad6295da5e7dab498ab75303257991822c40 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sun, 22 Mar 2020 14:41:01 -0400 Subject: [PATCH 11/49] validation exception handler --- app/main.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/app/main.py b/app/main.py index 409304e1..cf0410a6 100644 --- a/app/main.py +++ b/app/main.py @@ -1,9 +1,9 @@ """ app.main.py """ -import os -import logging import datetime as dt +import logging +import os from typing import Dict, List import fastapi @@ -93,6 +93,18 @@ async def add_datasource(request: fastapi.Request, call_next): return response +# ################ +# Exception Handler +# ################ + + +@APP.exception_handler(pydantic.error_wrappers.ValidationError) +async def handle_validation_error( + request: fastapi.Request, exc: pydantic.error_wrappers.ValidationError +): + return fastapi.responses.JSONResponse({"message": exc.errors()}, status_code=422) + + # ################ # Routes # ################ @@ -112,7 +124,7 @@ def get_latest(request: fastapi.Request): @APP.get("/locations", response_model=AllLocations) -def get_all_locations(country_code: str = None, timelines: int = None): +def get_all_locations(country_code: str = None, timelines: int = 0): return From 20c49c28629e0ac6e29549f47b2e154040d43c0c Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sun, 22 Mar 2020 14:47:56 -0400 Subject: [PATCH 12/49] get location by id --- app/main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/main.py b/app/main.py index cf0410a6..d4a91449 100644 --- a/app/main.py +++ b/app/main.py @@ -129,8 +129,8 @@ def get_all_locations(country_code: str = None, timelines: int = 0): @APP.get("/locations/{id}", response_model=Location) -def get_location_by_id(id: int): - return +def get_location_by_id(request: fastapi.Request, id: int, timelines: int = 1): + return {"location": request.state.source.get(id).serialize(timelines)} if __name__ == "__main__": From 1cfdc2c2fa297c381e8402594554f076e80a7b41 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sun, 22 Mar 2020 15:47:28 -0400 Subject: [PATCH 13/49] WIP get_all_locations --- app/main.py | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/app/main.py b/app/main.py index d4a91449..1096f842 100644 --- a/app/main.py +++ b/app/main.py @@ -4,6 +4,7 @@ import datetime as dt import logging import os +import reprlib from typing import Dict, List import fastapi @@ -46,11 +47,11 @@ class Country(pydantic.BaseModel): last_updated: dt.datetime latest: Totals province: str = "" - timelines: TimelinedLocation + timelines: TimelinedLocation = None # FIXME class AllLocations(pydantic.BaseModel): - latest: Totals + latest: Totals = None # FIXME locations: List[Country] @@ -124,8 +125,25 @@ def get_latest(request: fastapi.Request): @APP.get("/locations", response_model=AllLocations) -def get_all_locations(country_code: str = None, timelines: int = 0): - return +def get_all_locations( + request: fastapi.Request, country_code: str = None, timelines: int = 0 +): + # Retrieve all the locations. + locations = request.state.source.get_all() + + # Filtering my country code if provided. + if country_code: + locations = list( + filter( + lambda location: location.country_code == country_code.upper(), + locations, + ) + ) + response_dict = { + "locations": [location.serialize(timelines) for location in locations] + } + LOGGER.info(f"response: {reprlib.repr(response_dict)}") + return response_dict @APP.get("/locations/{id}", response_model=Location) From e8e50e802b3df8840f4494fd86c2da2dff5c15aa Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sun, 22 Mar 2020 16:21:47 -0400 Subject: [PATCH 14/49] add Totals --- app/main.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/app/main.py b/app/main.py index 1096f842..c166c79c 100644 --- a/app/main.py +++ b/app/main.py @@ -51,7 +51,7 @@ class Country(pydantic.BaseModel): class AllLocations(pydantic.BaseModel): - latest: Totals = None # FIXME + latest: Totals locations: List[Country] @@ -139,11 +139,14 @@ def get_all_locations( locations, ) ) - response_dict = { - "locations": [location.serialize(timelines) for location in locations] + 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], } - LOGGER.info(f"response: {reprlib.repr(response_dict)}") - return response_dict @APP.get("/locations/{id}", response_model=Location) From 94a2f4e8c0dee8ae7af34c2f8f52bfd923f5aefa Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sun, 22 Mar 2020 16:45:12 -0400 Subject: [PATCH 15/49] Mount v2 of the application --- app/__init__.py | 25 +------------------------ app/core.py | 24 ++++++++++++++++++++++++ app/main.py | 4 ++++ 3 files changed, 29 insertions(+), 24 deletions(-) create mode 100644 app/core.py diff --git a/app/__init__.py b/app/__init__.py index 9861d8b9..ed9f7bab 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 \ No newline at end of file diff --git a/app/core.py b/app/core.py new file mode 100644 index 00000000..cb0cc9ba --- /dev/null +++ b/app/core.py @@ -0,0 +1,24 @@ +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) + app.register_blueprint(routes.api_v2) + + # Return created app. + return app \ No newline at end of file diff --git a/app/main.py b/app/main.py index c166c79c..9cf45720 100644 --- a/app/main.py +++ b/app/main.py @@ -10,8 +10,10 @@ import fastapi import pydantic import uvicorn +from fastapi.middleware.wsgi import WSGIMiddleware from .data import data_source +from .core import create_app # ################################# # Models @@ -153,6 +155,8 @@ def get_all_locations( def get_location_by_id(request: fastapi.Request, id: int, timelines: int = 1): return {"location": request.state.source.get(id).serialize(timelines)} +# mount the existing Flask app to /v2 +APP.mount("/v2", WSGIMiddleware(create_app())) if __name__ == "__main__": uvicorn.run( From 091b461d67e6197c2f8b2170bad826046b49ed10 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sun, 22 Mar 2020 16:47:12 -0400 Subject: [PATCH 16/49] add FIXME for timelines --- app/main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/main.py b/app/main.py index 9cf45720..51b9b32d 100644 --- a/app/main.py +++ b/app/main.py @@ -141,6 +141,7 @@ def get_all_locations( locations, ) ) + # FIXME: timelines are not showing up return { "latest": { "confirmed": sum(map(lambda location: location.confirmed, locations)), From ce9374ce808540eb9b8d16af60b5069769ada6b8 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sun, 22 Mar 2020 17:05:47 -0400 Subject: [PATCH 17/49] end of file newlines --- Pipfile | 2 +- app/__init__.py | 2 +- app/core.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Pipfile b/Pipfile index 297fb5cb..d6ad6732 100644 --- a/Pipfile +++ b/Pipfile @@ -24,4 +24,4 @@ python_version = "3.8" [scripts] dev_app = "uvicorn app.main:APP --reload" -app = "uvicorn app.main:APP" \ No newline at end of file +app = "uvicorn app.main:APP" diff --git a/app/__init__.py b/app/__init__.py index ed9f7bab..b4b15379 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,4 +1,4 @@ # See PEP396. __version__ = '2.0' -from .core import create_app \ No newline at end of file +from .core import create_app diff --git a/app/core.py b/app/core.py index cb0cc9ba..a77b37b3 100644 --- a/app/core.py +++ b/app/core.py @@ -21,4 +21,4 @@ def create_app(): app.register_blueprint(routes.api_v2) # Return created app. - return app \ No newline at end of file + return app From 0d2f5f328ce14fcaed450ee218d44aa0eb32fe4a Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sun, 22 Mar 2020 18:03:23 -0400 Subject: [PATCH 18/49] move models to models.py --- app/main.py | 56 ++++++--------------------------------------------- app/models.py | 50 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 50 deletions(-) create mode 100644 app/models.py diff --git a/app/main.py b/app/main.py index 51b9b32d..b1b6c8a9 100644 --- a/app/main.py +++ b/app/main.py @@ -12,54 +12,9 @@ import uvicorn from fastapi.middleware.wsgi import WSGIMiddleware -from .data import data_source +from . import models from .core import create_app - -# ################################# -# Models -# ################################# - - -class Totals(pydantic.BaseModel): - confirmed: int - deaths: int - recovered: int - - -class Latest(pydantic.BaseModel): - latest: Totals - - -class TimelineStats(pydantic.BaseModel): - latest: int - timeline: Dict[str, int] - - -class TimelinedLocation(pydantic.BaseModel): - confirmed: TimelineStats - deaths: TimelineStats - recovered: TimelineStats - - -class Country(pydantic.BaseModel): - coordinates: Dict - country: str - country_code: str - id: int - last_updated: dt.datetime - latest: Totals - province: str = "" - timelines: TimelinedLocation = None # FIXME - - -class AllLocations(pydantic.BaseModel): - latest: Totals - locations: List[Country] - - -class Location(pydantic.BaseModel): - location: Country - +from .data import data_source # ################ # Dependencies @@ -113,7 +68,7 @@ async def handle_validation_error( # ################ -@APP.get("/latest", response_model=Latest) +@APP.get("/latest", response_model=models.Latest) def get_latest(request: fastapi.Request): """Getting latest amount of total confirmed cases, deaths, and recoveries.""" locations = request.state.source.get_all() @@ -126,7 +81,7 @@ def get_latest(request: fastapi.Request): } -@APP.get("/locations", response_model=AllLocations) +@APP.get("/locations", response_model=models.AllLocations) def get_all_locations( request: fastapi.Request, country_code: str = None, timelines: int = 0 ): @@ -152,10 +107,11 @@ def get_all_locations( } -@APP.get("/locations/{id}", response_model=Location) +@APP.get("/locations/{id}", response_model=models.Location) def get_location_by_id(request: fastapi.Request, id: int, timelines: int = 1): return {"location": request.state.source.get(id).serialize(timelines)} + # mount the existing Flask app to /v2 APP.mount("/v2", WSGIMiddleware(create_app())) diff --git a/app/models.py b/app/models.py new file mode 100644 index 00000000..a67b7573 --- /dev/null +++ b/app/models.py @@ -0,0 +1,50 @@ +""" +app.models.py +~~~~~~~~~~~~~ +Reponse data models. +""" +import datetime as dt +from typing import Dict, List + +import pydantic + + +class Totals(pydantic.BaseModel): + confirmed: int + deaths: int + recovered: int + + +class Latest(pydantic.BaseModel): + latest: Totals + + +class TimelineStats(pydantic.BaseModel): + latest: int + timeline: Dict[str, int] + + +class TimelinedLocation(pydantic.BaseModel): + confirmed: TimelineStats + deaths: TimelineStats + recovered: TimelineStats + + +class Country(pydantic.BaseModel): + coordinates: Dict + country: str + country_code: str + id: int + last_updated: dt.datetime + latest: Totals + province: str = "" + timelines: TimelinedLocation = None # FIXME + + +class AllLocations(pydantic.BaseModel): + latest: Totals + locations: List[Country] + + +class Location(pydantic.BaseModel): + location: Country From 04a35d897af1ac22389cb823187b08dc992ebece Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sun, 22 Mar 2020 18:22:39 -0400 Subject: [PATCH 19/49] exclude unset --- app/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/main.py b/app/main.py index b1b6c8a9..514b8642 100644 --- a/app/main.py +++ b/app/main.py @@ -81,7 +81,7 @@ def get_latest(request: fastapi.Request): } -@APP.get("/locations", response_model=models.AllLocations) +@APP.get("/locations", response_model=models.AllLocations, response_model_exclude_unset=True) def get_all_locations( request: fastapi.Request, country_code: str = None, timelines: int = 0 ): From 6ecbc26730bc4ba325b335e16714bb448c402c4b Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sun, 22 Mar 2020 20:11:00 -0400 Subject: [PATCH 20/49] gunicorn with FastAPI --- Procfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 43bdeb0b2d72c9c768b1358e6b82a78a19c9eea1 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sun, 22 Mar 2020 20:13:32 -0400 Subject: [PATCH 21/49] change version name --- app/main.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/main.py b/app/main.py index 514b8642..ae807e26 100644 --- a/app/main.py +++ b/app/main.py @@ -29,9 +29,9 @@ APP = fastapi.FastAPI( title="Coronavirus Tracker", description="API for tracking the global coronavirus (COVID-19, SARS-CoV-2) outbreak.", - version="2.1.0", - prefix="/v2-1", - docs_url="/v2-1", + version="2.0.1", + prefix="/v2-beta", + docs_url="/v2-beta", redoc_url="/docs", ) From 86617e6c2038fc07e8ca4a0a08158810c8ed085f Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sun, 22 Mar 2020 20:14:40 -0400 Subject: [PATCH 22/49] create Sources enum --- app/main.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/app/main.py b/app/main.py index ae807e26..4683e79e 100644 --- a/app/main.py +++ b/app/main.py @@ -2,6 +2,7 @@ app.main.py """ import datetime as dt +import enum import logging import os import reprlib @@ -21,6 +22,11 @@ # ################ +class Sources(str, enum.Enum): + jhu = "jhu" + csbs = "csbs" + + # ############ # FastAPI App # ############ @@ -81,9 +87,14 @@ def get_latest(request: fastapi.Request): } -@APP.get("/locations", response_model=models.AllLocations, response_model_exclude_unset=True) +@APP.get( + "/locations", response_model=models.AllLocations, response_model_exclude_unset=True +) def get_all_locations( - request: fastapi.Request, country_code: str = None, timelines: int = 0 + request: fastapi.Request, + country_code: str = None, + timelines: int = 0, + source: Sources = "jhu", ): # Retrieve all the locations. locations = request.state.source.get_all() From 21e952cc43469ea43a2a89bc3849b5d22786abbb Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sun, 22 Mar 2020 21:28:35 -0400 Subject: [PATCH 23/49] expose v1 and v2 apis via mounted WSGI app --- app/main.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/main.py b/app/main.py index 4683e79e..81d00b12 100644 --- a/app/main.py +++ b/app/main.py @@ -123,8 +123,10 @@ def get_location_by_id(request: fastapi.Request, id: int, timelines: int = 1): return {"location": request.state.source.get(id).serialize(timelines)} -# mount the existing Flask app to /v2 -APP.mount("/v2", WSGIMiddleware(create_app())) +# mount the existing Flask app +# v1 @ / +# v2 @ /v2 +APP.mount("/", WSGIMiddleware(create_app())) if __name__ == "__main__": uvicorn.run( From 0a2a262e109d0a8ab945f300fa521a2ccc3897a6 Mon Sep 17 00:00:00 2001 From: Gabriel Gore Date: Mon, 23 Mar 2020 07:12:01 -0400 Subject: [PATCH 24/49] lock dependencies for linux machines --- Pipfile.lock | 55 ++++++++++++++++++++++++++++++++++------------------ 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index d505e349..b79d6b4b 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -84,6 +84,24 @@ ], "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", @@ -215,6 +233,21 @@ "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", @@ -258,14 +291,6 @@ ], "version": "==2.3.3" }, - "atomicwrites": { - "hashes": [ - "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4", - "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6" - ], - "markers": "sys_platform == 'win32'", - "version": "==1.3.0" - }, "attrs": { "hashes": [ "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", @@ -281,14 +306,6 @@ "index": "pypi", "version": "==1.6.2" }, - "colorama": { - "hashes": [ - "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff", - "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1" - ], - "markers": "sys_platform == 'win32'", - "version": "==0.4.3" - }, "gitdb": { "hashes": [ "sha256:284a6a4554f954d6e737cddcff946404393e030b76a282c6640df8efd6b3da5e", @@ -440,10 +457,10 @@ }, "wcwidth": { "hashes": [ - "sha256:8fd29383f539be45b20bd4df0dc29c20ba48654a41e661925e612311e9f3c603", - "sha256:f28b3e8a6483e5d49e7f8949ac1a78314e740333ae305b4ba5defd3e74fb37a8" + "sha256:cafe2186b3c009a04067022ce1dcd79cb38d8d65ee4f4791b8888d6599d1bbe1", + "sha256:ee73862862a156bf77ff92b09034fc4825dd3af9cf81bc5b360668d425f3c5f1" ], - "version": "==0.1.8" + "version": "==0.1.9" }, "wrapt": { "hashes": [ From 8a199592cde40259729f2142d2ba655163478ea3 Mon Sep 17 00:00:00 2001 From: Gabriel Gore Date: Mon, 23 Mar 2020 07:37:03 -0400 Subject: [PATCH 25/49] specify python 3.8 runtime for Heroku --- runtime.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 runtime.txt 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 From ab817bd9f91ef0c1e53413bd2d4b17d0c6733e9e Mon Sep 17 00:00:00 2001 From: Gabriel Date: Mon, 23 Mar 2020 21:11:46 -0400 Subject: [PATCH 26/49] add sources --- app/main.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/app/main.py b/app/main.py index 81d00b12..3683de48 100644 --- a/app/main.py +++ b/app/main.py @@ -15,7 +15,7 @@ from . import models from .core import create_app -from .data import data_source +from .data import data_source, data_sources # ################ # Dependencies @@ -123,6 +123,16 @@ def get_location_by_id(request: fastapi.Request, id: int, timelines: int = 1): return {"location": request.state.source.get(id).serialize(timelines)} +@APP.get('/sources') +async def sources(): + """ + Retrieves a list of data-sources that are availble to use. + """ + return { + 'sources': list(data_sources.keys()) + } + + # mount the existing Flask app # v1 @ / # v2 @ /v2 From ece30f396863d1b0debafceef7f96829b865f4ed Mon Sep 17 00:00:00 2001 From: Gabriel Date: Mon, 23 Mar 2020 21:13:21 -0400 Subject: [PATCH 27/49] update prefix --- app/main.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/main.py b/app/main.py index 3683de48..696832f9 100644 --- a/app/main.py +++ b/app/main.py @@ -36,8 +36,7 @@ class Sources(str, enum.Enum): title="Coronavirus Tracker", description="API for tracking the global coronavirus (COVID-19, SARS-CoV-2) outbreak.", version="2.0.1", - prefix="/v2-beta", - docs_url="/v2-beta", + docs_url="/", redoc_url="/docs", ) From aec80ac0ab7f87b22f21824c2c6da5158ae0dbc7 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Mon, 23 Mar 2020 21:25:39 -0400 Subject: [PATCH 28/49] use separate v2 router --- app/main.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/app/main.py b/app/main.py index 696832f9..e67766c8 100644 --- a/app/main.py +++ b/app/main.py @@ -72,8 +72,10 @@ async def handle_validation_error( # Routes # ################ +V2 = fastapi.APIRouter() -@APP.get("/latest", response_model=models.Latest) + +@V2.get("/latest", response_model=models.Latest) def get_latest(request: fastapi.Request): """Getting latest amount of total confirmed cases, deaths, and recoveries.""" locations = request.state.source.get_all() @@ -86,7 +88,7 @@ def get_latest(request: fastapi.Request): } -@APP.get( +@V2.get( "/locations", response_model=models.AllLocations, response_model_exclude_unset=True ) def get_all_locations( @@ -117,12 +119,12 @@ def get_all_locations( } -@APP.get("/locations/{id}", response_model=models.Location) +@V2.get("/locations/{id}", response_model=models.Location) def get_location_by_id(request: fastapi.Request, id: int, timelines: int = 1): return {"location": request.state.source.get(id).serialize(timelines)} -@APP.get('/sources') +@V2.get('/sources') async def sources(): """ Retrieves a list of data-sources that are availble to use. @@ -132,6 +134,7 @@ async def sources(): } +APP.include_router(V2, prefix="/v2-beta", tags=["v2"]) # mount the existing Flask app # v1 @ / # v2 @ /v2 From 1949beaf81ce215b33baf673799fdb3eba0d12ca Mon Sep 17 00:00:00 2001 From: Gabriel Date: Mon, 23 Mar 2020 18:22:43 -0400 Subject: [PATCH 29/49] add sources and make Timelines a boolean --- app/main.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/app/main.py b/app/main.py index e67766c8..5b62ac9c 100644 --- a/app/main.py +++ b/app/main.py @@ -76,7 +76,7 @@ async def handle_validation_error( @V2.get("/latest", response_model=models.Latest) -def get_latest(request: fastapi.Request): +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 { @@ -94,7 +94,7 @@ def get_latest(request: fastapi.Request): def get_all_locations( request: fastapi.Request, country_code: str = None, - timelines: int = 0, + timelines: bool = False, source: Sources = "jhu", ): # Retrieve all the locations. @@ -120,18 +120,16 @@ def get_all_locations( @V2.get("/locations/{id}", response_model=models.Location) -def get_location_by_id(request: fastapi.Request, id: int, timelines: int = 1): +def get_location_by_id(request: fastapi.Request, id: int, timelines: bool = True): return {"location": request.state.source.get(id).serialize(timelines)} -@V2.get('/sources') +@V2.get("/sources") async def sources(): """ Retrieves a list of data-sources that are availble to use. """ - return { - 'sources': list(data_sources.keys()) - } + return {"sources": list(data_sources.keys())} APP.include_router(V2, prefix="/v2-beta", tags=["v2"]) From 9d22cd59f5630de6f318cdc4406fd295d2ff366b Mon Sep 17 00:00:00 2001 From: Gabriel Date: Mon, 23 Mar 2020 18:33:16 -0400 Subject: [PATCH 30/49] Fast api conversion (#4) * add FastAPI uvicorn * define app * create provisional models * define latest, location endpoints * update models * change model name to match pre-existing model * defaults * update versions and response models * divide sections * create middleware to attach the "data_source" https://www.starlette.io/requests/#other-state * validation exception handler * get location by id * WIP get_all_locations * add Totals * Mount v2 of the application * add FIXME for timelines * end of file newlines * move models to models.py * exclude unset * gunicorn with FastAPI * change version name * create Sources enum * expose v1 and v2 apis via mounted WSGI app * lock dependencies for linux machines * specify python 3.8 runtime for Heroku * add sources and make Timelines a boolean * add sources * update prefix * use separate v2 router Co-authored-by: ExpDev --- Pipfile | 6 ++ Pipfile.lock | 183 +++++++++++++++++++++++++++++++++--------------- Procfile | 2 +- app/__init__.py | 25 +------ app/core.py | 24 +++++++ app/main.py | 147 ++++++++++++++++++++++++++++++++++++++ app/models.py | 50 +++++++++++++ runtime.txt | 1 + 8 files changed, 355 insertions(+), 83 deletions(-) create mode 100644 app/core.py create mode 100644 app/main.py create mode 100644 app/models.py create mode 100644 runtime.txt diff --git a/Pipfile b/Pipfile index 104c27c8..d6ad6732 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_app = "uvicorn app.main:APP --reload" +app = "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/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..a77b37b3 --- /dev/null +++ b/app/core.py @@ -0,0 +1,24 @@ +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) + app.register_blueprint(routes.api_v2) + + # Return created app. + return app diff --git a/app/main.py b/app/main.py new file mode 100644 index 00000000..5b62ac9c --- /dev/null +++ b/app/main.py @@ -0,0 +1,147 @@ +""" +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): + 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.", + 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.""" + source = request.query_params.get("source", default="jhu") + request.state.source = data_source(source) + LOGGER.info(f"source: {request.state.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 +): + return fastapi.responses.JSONResponse({"message": exc.errors()}, status_code=422) + + +# ################ +# Routes +# ################ + +V2 = fastapi.APIRouter() + + +@V2.get("/latest", response_model=models.Latest) +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.AllLocations, response_model_exclude_unset=True +) +def get_all_locations( + request: fastapi.Request, + country_code: str = None, + timelines: bool = False, + source: Sources = "jhu", +): + # Retrieve all the locations. + locations = request.state.source.get_all() + + # Filtering my country code if provided. + if country_code: + locations = list( + filter( + lambda location: location.country_code == country_code.upper(), + locations, + ) + ) + # FIXME: timelines are not showing up + 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.Location) +def get_location_by_id(request: fastapi.Request, id: int, timelines: bool = True): + 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())} + + +APP.include_router(V2, prefix="/v2-beta", tags=["v2"]) +# mount the existing Flask app +# v1 @ / +# v2 @ /v2 +APP.mount("/", WSGIMiddleware(create_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..a67b7573 --- /dev/null +++ b/app/models.py @@ -0,0 +1,50 @@ +""" +app.models.py +~~~~~~~~~~~~~ +Reponse data models. +""" +import datetime as dt +from typing import Dict, List + +import pydantic + + +class Totals(pydantic.BaseModel): + confirmed: int + deaths: int + recovered: int + + +class Latest(pydantic.BaseModel): + latest: Totals + + +class TimelineStats(pydantic.BaseModel): + latest: int + timeline: Dict[str, int] + + +class TimelinedLocation(pydantic.BaseModel): + confirmed: TimelineStats + deaths: TimelineStats + recovered: TimelineStats + + +class Country(pydantic.BaseModel): + coordinates: Dict + country: str + country_code: str + id: int + last_updated: dt.datetime + latest: Totals + province: str = "" + timelines: TimelinedLocation = None # FIXME + + +class AllLocations(pydantic.BaseModel): + latest: Totals + locations: List[Country] + + +class Location(pydantic.BaseModel): + location: Country 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 From f7b7354cef0830e291f22ae6b776ddaf629f7e66 Mon Sep 17 00:00:00 2001 From: ExpDev07 Date: Tue, 24 Mar 2020 02:54:18 +0100 Subject: [PATCH 31/49] fixed wrong date format and timelines appearing null --- app/main.py | 5 +- app/models.py | 33 +++--- coronavirus-tracker-api-swagger.yaml | 152 --------------------------- 3 files changed, 18 insertions(+), 172 deletions(-) delete mode 100644 coronavirus-tracker-api-swagger.yaml diff --git a/app/main.py b/app/main.py index 5b62ac9c..8e0b367c 100644 --- a/app/main.py +++ b/app/main.py @@ -89,7 +89,7 @@ def get_latest(request: fastapi.Request, source: Sources = "jhu"): @V2.get( - "/locations", response_model=models.AllLocations, response_model_exclude_unset=True + "/locations", response_model=models.Locations, response_model_exclude_unset=True ) def get_all_locations( request: fastapi.Request, @@ -131,8 +131,9 @@ async def sources(): """ return {"sources": list(data_sources.keys())} - +# Include routers. APP.include_router(V2, prefix="/v2-beta", tags=["v2"]) + # mount the existing Flask app # v1 @ / # v2 @ /v2 diff --git a/app/models.py b/app/models.py index a67b7573..36b1396f 100644 --- a/app/models.py +++ b/app/models.py @@ -3,48 +3,45 @@ ~~~~~~~~~~~~~ Reponse data models. """ -import datetime as dt +from pydantic import BaseModel from typing import Dict, List -import pydantic - - -class Totals(pydantic.BaseModel): +class Totals(BaseModel): confirmed: int deaths: int recovered: int -class Latest(pydantic.BaseModel): +class Latest(BaseModel): latest: Totals -class TimelineStats(pydantic.BaseModel): +class TimelineStats(BaseModel): latest: int - timeline: Dict[str, int] + timeline: Dict[str, int] = {} -class TimelinedLocation(pydantic.BaseModel): +class TimelinedLocation(BaseModel): confirmed: TimelineStats deaths: TimelineStats recovered: TimelineStats -class Country(pydantic.BaseModel): - coordinates: Dict +class Country(BaseModel): + id: int country: str country_code: str - id: int - last_updated: dt.datetime - latest: Totals province: str = "" - timelines: TimelinedLocation = None # FIXME + last_updated: str # TODO use datetime.datetime type. + coordinates: Dict + latest: Totals + timelines: TimelinedLocation = {} -class AllLocations(pydantic.BaseModel): +class Locations(BaseModel): latest: Totals - locations: List[Country] + locations: List[Country] = [] -class Location(pydantic.BaseModel): +class Location(BaseModel): location: Country 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 From f0e36a2e1c220d01f57ef445b6025f51dd731802 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Tue, 24 Mar 2020 18:25:29 -0400 Subject: [PATCH 32/49] replace Flask based v2 --- app/core.py | 1 - app/main.py | 3 +-- app/routes/__init__.py | 21 ------------------- app/routes/v2/__init__.py | 0 app/routes/v2/latest.py | 18 ---------------- app/routes/v2/locations.py | 42 -------------------------------------- app/routes/v2/sources.py | 12 ----------- 7 files changed, 1 insertion(+), 96 deletions(-) delete mode 100644 app/routes/v2/__init__.py delete mode 100644 app/routes/v2/latest.py delete mode 100644 app/routes/v2/locations.py delete mode 100644 app/routes/v2/sources.py diff --git a/app/core.py b/app/core.py index a77b37b3..d631deb4 100644 --- a/app/core.py +++ b/app/core.py @@ -18,7 +18,6 @@ def create_app(): # Register api endpoints. app.register_blueprint(routes.api_v1) - app.register_blueprint(routes.api_v2) # Return created app. return app diff --git a/app/main.py b/app/main.py index 8e0b367c..47d2dd63 100644 --- a/app/main.py +++ b/app/main.py @@ -132,11 +132,10 @@ async def sources(): return {"sources": list(data_sources.keys())} # Include routers. -APP.include_router(V2, prefix="/v2-beta", tags=["v2"]) +APP.include_router(V2, prefix="/v2", tags=["v2"]) # mount the existing Flask app # v1 @ / -# v2 @ /v2 APP.mount("/", WSGIMiddleware(create_app())) if __name__ == "__main__": 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()) - }) From 52028760cd338537cb94de8fda30967056b6d0a2 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Tue, 24 Mar 2020 18:44:04 -0400 Subject: [PATCH 33/49] Fix existing tests --- tests/test_routes.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/tests/test_routes.py b/tests/test_routes.py index e08eb349..5bcf0f69 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -1,8 +1,10 @@ import app import unittest +from fastapi.testclient import TestClient import json from unittest import mock from app import services +from app.main import APP from .test_jhu import mocked_requests_get, mocked_strptime_isoformat, DATETIME_STRING @@ -20,6 +22,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 +32,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("/") + """Validate that / returns content and is not a redirect.""" + response = self.asgi_client.get("/") - assert return_data.status_code == 302 - - 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 +78,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 +94,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 +108,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: From 129a153adb949ebce16c4390826cf55724c3fc33 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Tue, 24 Mar 2020 18:59:22 -0400 Subject: [PATCH 34/49] add api_client testing client fixture and basic swagger doc tests --- tests/conftest.py | 18 ++++++++++++++++++ tests/test_routes.py | 2 +- tests/test_swagger.py | 9 +++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_swagger.py 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 5bcf0f69..2006724e 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -32,7 +32,7 @@ def read_file_v1(self, state): return expected_json_output def test_root_api(self, mock_request_get, mock_datetime): - """Validate that / returns content and is not a redirect.""" + """Validate that / returns a 200 and is not a redirect.""" response = self.asgi_client.get("/") assert response.status_code == 200 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 From d343a3548831331c8048e534daee001ea9b008aa Mon Sep 17 00:00:00 2001 From: Gabriel Date: Tue, 24 Mar 2020 19:15:43 -0400 Subject: [PATCH 35/49] add Swagger information to the Readme --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 840efc72..7220ff9f 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,14 @@ 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 + +An [interactive SwaggerUI](https://coronavirus-tracker-api.herokuapp.com/) exists for the `v2` endpoints. + +Mobile friendly ReDocs can be reached at https://coronavirus-tracker-api.herokuapp.com/docs + +The [OpenAPI](https://swagger.io/docs/specification/about/) json can be downloaded at https://coronavirus-tracker-api.herokuapp.com/openapi.json + ## API Endpoints ### Sources Endpoint From 8b7d29945fe9f7e12728e61a38e5d508c82e564f Mon Sep 17 00:00:00 2001 From: ExpDev Date: Wed, 25 Mar 2020 00:16:22 +0100 Subject: [PATCH 36/49] Improve middleware. --- app/main.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/app/main.py b/app/main.py index 47d2dd63..afaed09a 100644 --- a/app/main.py +++ b/app/main.py @@ -49,9 +49,18 @@ class Sources(str, enum.Enum): @APP.middleware("http") async def add_datasource(request: fastapi.Request, call_next): """Attach the data source to the request.state.""" - source = request.query_params.get("source", default="jhu") - request.state.source = data_source(source) - LOGGER.info(f"source: {request.state.source.__class__.__name__}") + # Retrieve the datas ource from query param. + source = data_source(request.query_params.get('source', type=str, default='jhu')) + + # Abort with 404 if source cannot be found. + if not source: + raise HTTPException(status_code=404, detail='The provided data-source was not found.') + + # Attach source to request. + request.state.source = source + + # Move on... + LOGGER.info(f"source: {source.__class__.__name__}") response = await call_next(request) return response From 2a6892b4117b17c4b34845254fbfd3c718e2e697 Mon Sep 17 00:00:00 2001 From: ExpDev07 Date: Wed, 25 Mar 2020 00:21:38 +0100 Subject: [PATCH 37/49] fix tests --- app/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/main.py b/app/main.py index afaed09a..d2ebaa81 100644 --- a/app/main.py +++ b/app/main.py @@ -50,7 +50,7 @@ class Sources(str, enum.Enum): 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', type=str, default='jhu')) + source = data_source(request.query_params.get('source', default='jhu')) # Abort with 404 if source cannot be found. if not source: From 6b4447b6be45498665995189502200a28a73ae74 Mon Sep 17 00:00:00 2001 From: ExpDev07 Date: Wed, 25 Mar 2020 00:28:13 +0100 Subject: [PATCH 38/49] cleanup and use single quotes to follow same conventions --- app/main.py | 72 ++++++++++++++++++------------------ app/models.py | 2 +- app/services/location/jhu.py | 2 - 3 files changed, 37 insertions(+), 39 deletions(-) diff --git a/app/main.py b/app/main.py index d2ebaa81..9d726c84 100644 --- a/app/main.py +++ b/app/main.py @@ -23,21 +23,21 @@ class Sources(str, enum.Enum): - jhu = "jhu" - csbs = "csbs" + jhu = 'jhu' + csbs = 'csbs' # ############ # FastAPI App # ############ -LOGGER = logging.getLogger("api") +LOGGER = logging.getLogger('api') APP = fastapi.FastAPI( - title="Coronavirus Tracker", - description="API for tracking the global coronavirus (COVID-19, SARS-CoV-2) outbreak.", - version="2.0.1", - docs_url="/", - redoc_url="/docs", + title='Coronavirus Tracker', + description='API for tracking the global coronavirus (COVID-19, SARS-CoV-2) outbreak.', + version='2.0.1', + docs_url='/', + redoc_url='/docs', ) # ##################### @@ -45,8 +45,8 @@ class Sources(str, enum.Enum): ####################### -# TODO this could probably just be a FastAPI dependency -@APP.middleware("http") +# 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. @@ -60,7 +60,7 @@ async def add_datasource(request: fastapi.Request, call_next): request.state.source = source # Move on... - LOGGER.info(f"source: {source.__class__.__name__}") + LOGGER.info(f'source: {source.__class__.__name__}') response = await call_next(request) return response @@ -74,7 +74,7 @@ async def add_datasource(request: fastapi.Request, call_next): async def handle_validation_error( request: fastapi.Request, exc: pydantic.error_wrappers.ValidationError ): - return fastapi.responses.JSONResponse({"message": exc.errors()}, status_code=422) + return fastapi.responses.JSONResponse({'message': exc.errors()}, status_code=422) # ################ @@ -84,27 +84,27 @@ async def handle_validation_error( V2 = fastapi.APIRouter() -@V2.get("/latest", response_model=models.Latest) -def get_latest(request: fastapi.Request, source: Sources = "jhu"): +@V2.get('/latest', response_model=models.Latest) +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)), + '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.Locations, response_model_exclude_unset=True + '/locations', response_model=models.Locations, response_model_exclude_unset=True ) def get_all_locations( request: fastapi.Request, country_code: str = None, timelines: bool = False, - source: Sources = "jhu", + source: Sources = 'jhu', ): # Retrieve all the locations. locations = request.state.source.get_all() @@ -119,38 +119,38 @@ def get_all_locations( ) # FIXME: timelines are not showing up 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)), + '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], + 'locations': [location.serialize(timelines) for location in locations], } -@V2.get("/locations/{id}", response_model=models.Location) +@V2.get('/locations/{id}', response_model=models.Location) def get_location_by_id(request: fastapi.Request, id: int, timelines: bool = True): - return {"location": request.state.source.get(id).serialize(timelines)} + return {'location': request.state.source.get(id).serialize(timelines)} -@V2.get("/sources") +@V2.get('/sources') async def sources(): """ Retrieves a list of data-sources that are availble to use. """ - return {"sources": list(data_sources.keys())} + return {'sources': list(data_sources.keys())} # Include routers. -APP.include_router(V2, prefix="/v2", tags=["v2"]) +APP.include_router(V2, prefix='/v2', tags=['v2']) # mount the existing Flask app # v1 @ / -APP.mount("/", WSGIMiddleware(create_app())) +APP.mount('/', WSGIMiddleware(create_app())) -if __name__ == "__main__": +if __name__ == '__main__': uvicorn.run( - "app.main:APP", - host="127.0.0.1", - port=int(os.getenv("PORT", 5000)), - log_level="info", + '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 index 36b1396f..c625d89c 100644 --- a/app/models.py +++ b/app/models.py @@ -31,7 +31,7 @@ class Country(BaseModel): id: int country: str country_code: str - province: str = "" + province: str = '' last_updated: str # TODO use datetime.datetime type. coordinates: Dict latest: Totals 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 From 1dd1cf0fb58bedfaf0517571d8484845ca2f08aa Mon Sep 17 00:00:00 2001 From: ExpDev07 Date: Wed, 25 Mar 2020 00:39:48 +0100 Subject: [PATCH 39/49] fixes source middleware not returning 404 when invalid source is provided + doc changes + some cleanup --- app/main.py | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/app/main.py b/app/main.py index 9d726c84..276cef89 100644 --- a/app/main.py +++ b/app/main.py @@ -23,6 +23,9 @@ class Sources(str, enum.Enum): + """ + A source available for retrieving data. + """ jhu = 'jhu' csbs = 'csbs' @@ -48,13 +51,15 @@ class Sources(str, enum.Enum): # 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.""" + """ + 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: - raise HTTPException(status_code=404, detail='The provided data-source was not found.') + return fastapi.Response('The provided data-source was not found.', status_code=404) # Attach source to request. request.state.source = source @@ -74,6 +79,9 @@ async def add_datasource(request: fastapi.Request, call_next): 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) @@ -86,7 +94,9 @@ async def handle_validation_error( @V2.get('/latest', response_model=models.Latest) def get_latest(request: fastapi.Request, source: Sources = 'jhu'): - """Getting latest amount of total confirmed cases, deaths, and recoveries.""" + """ + Getting latest amount of total confirmed cases, deaths, and recoveries. + """ locations = request.state.source.get_all() return { 'latest': { @@ -106,6 +116,9 @@ def get_all_locations( timelines: bool = False, source: Sources = 'jhu', ): + """ + Getting all the locations. + """ # Retrieve all the locations. locations = request.state.source.get_all() @@ -130,7 +143,12 @@ def get_all_locations( @V2.get('/locations/{id}', response_model=models.Location) def get_location_by_id(request: fastapi.Request, id: int, timelines: bool = True): - return {'location': request.state.source.get(id).serialize(timelines)} + """ + Getting specific location by id. + """ + return { + 'location': request.state.source.get(id).serialize(timelines) + } @V2.get('/sources') @@ -138,7 +156,9 @@ async def sources(): """ Retrieves a list of data-sources that are availble to use. """ - return {'sources': list(data_sources.keys())} + return { + 'sources': list(data_sources.keys()) + } # Include routers. APP.include_router(V2, prefix='/v2', tags=['v2']) @@ -147,6 +167,7 @@ async def sources(): # v1 @ / APP.mount('/', WSGIMiddleware(create_app())) +# Running of app. if __name__ == '__main__': uvicorn.run( 'app.main:APP', From a0542f2eb7d7398a37077c51d06db8a5a0d97331 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Tue, 24 Mar 2020 20:26:33 -0400 Subject: [PATCH 40/49] add county to Country model --- app/models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/models.py b/app/models.py index c625d89c..72b4e442 100644 --- a/app/models.py +++ b/app/models.py @@ -31,6 +31,7 @@ class Country(BaseModel): id: int country: str country_code: str + county: str = None province: str = '' last_updated: str # TODO use datetime.datetime type. coordinates: Dict From 8978af1adffa5a0d00427be100b540a362260560 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Tue, 24 Mar 2020 20:40:25 -0400 Subject: [PATCH 41/49] test /locations codes --- tests/test_routes.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_routes.py b/tests/test_routes.py index 2006724e..8215cc36 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -118,3 +118,19 @@ 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"}, + {"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 From c7cb10c61d1a972acea9fba6a8ed84902cd106c8 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Tue, 24 Mar 2020 20:40:43 -0400 Subject: [PATCH 42/49] better swagger description --- app/main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/main.py b/app/main.py index 276cef89..f9dd8354 100644 --- a/app/main.py +++ b/app/main.py @@ -110,14 +110,14 @@ def get_latest(request: fastapi.Request, source: Sources = 'jhu'): @V2.get( '/locations', response_model=models.Locations, response_model_exclude_unset=True ) -def get_all_locations( +def get_locations( request: fastapi.Request, country_code: str = None, timelines: bool = False, source: Sources = 'jhu', ): """ - Getting all the locations. + Getting the locations. """ # Retrieve all the locations. locations = request.state.source.get_all() From 2854739c920f60f3b13b358c9e8aa56faf88780a Mon Sep 17 00:00:00 2001 From: Gabriel Date: Tue, 24 Mar 2020 20:43:22 -0400 Subject: [PATCH 43/49] forgot to import pytest --- tests/test_routes.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_routes.py b/tests/test_routes.py index 8215cc36..51c0421c 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -3,6 +3,7 @@ from fastapi.testclient import TestClient import json from unittest import mock +import pytest from app import services from app.main import APP From 45dc4d47ff9ec9904a4cbe2811bf9e71a09abc81 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Tue, 24 Mar 2020 22:41:12 -0400 Subject: [PATCH 44/49] add /latest test --- tests/test_routes.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/test_routes.py b/tests/test_routes.py index 51c0421c..dee93465 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -3,6 +3,7 @@ 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 @@ -128,6 +129,7 @@ def tearDown(self): {"source": "jhu"}, {"timelines": True}, {"timelines": "true"}, + {"timelines": 1}, {"source": "jhu", "timelines": True}, ], ) @@ -135,3 +137,26 @@ 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"] From 304de58f8db607913feb326e89243082e27c4c50 Mon Sep 17 00:00:00 2001 From: ExpDev07 Date: Wed, 25 Mar 2020 04:05:47 +0100 Subject: [PATCH 45/49] changed start dev script to "pipenv run dev" and "pipenv run start" + renamed models + some cleanup --- Pipfile | 4 ++-- README.md | 2 +- app/main.py | 22 +++++++++++----------- app/models.py | 38 +++++++++++++++++--------------------- 4 files changed, 31 insertions(+), 35 deletions(-) diff --git a/Pipfile b/Pipfile index d6ad6732..029ca2fb 100644 --- a/Pipfile +++ b/Pipfile @@ -23,5 +23,5 @@ uvicorn = "*" python_version = "3.8" [scripts] -dev_app = "uvicorn app.main:APP --reload" -app = "uvicorn app.main:APP" +dev = "uvicorn app.main:APP --reload" +start = "uvicorn app.main:APP" diff --git a/README.md b/README.md index 7220ff9f..839080f5 100644 --- a/README.md +++ b/README.md @@ -393,7 +393,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/main.py b/app/main.py index f9dd8354..1dddd2b8 100644 --- a/app/main.py +++ b/app/main.py @@ -65,7 +65,7 @@ async def add_datasource(request: fastapi.Request, call_next): request.state.source = source # Move on... - LOGGER.info(f'source: {source.__class__.__name__}') + LOGGER.info(f'source provided: {source.__class__.__name__}') response = await call_next(request) return response @@ -92,7 +92,7 @@ async def handle_validation_error( V2 = fastapi.APIRouter() -@V2.get('/latest', response_model=models.Latest) +@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. @@ -101,20 +101,20 @@ def get_latest(request: fastapi.Request, source: Sources = 'jhu'): return { 'latest': { 'confirmed': sum(map(lambda location: location.confirmed, locations)), - 'deaths': sum(map(lambda location: location.deaths, locations)), + 'deaths' : sum(map(lambda location: location.deaths, locations)), 'recovered': sum(map(lambda location: location.recovered, locations)), } } @V2.get( - '/locations', response_model=models.Locations, response_model_exclude_unset=True + '/locations', response_model=models.LocationsResponse, response_model_exclude_unset=True ) def get_locations( request: fastapi.Request, + source: Sources = 'jhu', country_code: str = None, timelines: bool = False, - source: Sources = 'jhu', ): """ Getting the locations. @@ -126,23 +126,23 @@ def get_locations( if country_code: locations = list( filter( - lambda location: location.country_code == country_code.upper(), - locations, + lambda location: location.country_code == country_code.upper(), locations, ) ) - # FIXME: timelines are not showing up + + # Return final serialized data. return { 'latest': { 'confirmed': sum(map(lambda location: location.confirmed, locations)), - 'deaths': sum(map(lambda location: location.deaths, 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.Location) -def get_location_by_id(request: fastapi.Request, id: int, timelines: bool = True): +@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. """ diff --git a/app/models.py b/app/models.py index 72b4e442..18ea72f7 100644 --- a/app/models.py +++ b/app/models.py @@ -6,43 +6,39 @@ from pydantic import BaseModel from typing import Dict, List -class Totals(BaseModel): +class Latest(BaseModel): confirmed: int deaths: int recovered: int +class LatestResponse(BaseModel): + latest: Latest -class Latest(BaseModel): - latest: Totals - - -class TimelineStats(BaseModel): +class Timeline(BaseModel): latest: int timeline: Dict[str, int] = {} +class Timelines(BaseModel): + confirmed: Timeline + deaths: Timeline + recovered: Timeline -class TimelinedLocation(BaseModel): - confirmed: TimelineStats - deaths: TimelineStats - recovered: TimelineStats - - -class Country(BaseModel): +class Location(BaseModel): id: int country: str country_code: str - county: str = None + county: str = '' province: str = '' last_updated: str # TODO use datetime.datetime type. coordinates: Dict - latest: Totals - timelines: TimelinedLocation = {} + latest: Latest + timelines: Timelines = {} -class Locations(BaseModel): - latest: Totals - locations: List[Country] = [] +class LocationsResponse(BaseModel): + latest: Latest + locations: List[Location] = [] -class Location(BaseModel): - location: Country +class LocationResponse(BaseModel): + location: Location From 88a6fafe5bfa664b34e6341d923c79a67dfdd652 Mon Sep 17 00:00:00 2001 From: ExpDev07 Date: Wed, 25 Mar 2020 04:42:16 +0100 Subject: [PATCH 46/49] fixed querying of all properties --- app/main.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/app/main.py b/app/main.py index 1dddd2b8..f384b3ce 100644 --- a/app/main.py +++ b/app/main.py @@ -114,21 +114,30 @@ 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() - # Filtering my country code if provided. - if country_code: - locations = list( - filter( - lambda location: location.country_code == country_code.upper(), locations, - ) - ) + # 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 { From a55e03e9bb4fac845578bbc0a8dc06c6d8a2282b Mon Sep 17 00:00:00 2001 From: ExpDev07 Date: Wed, 25 Mar 2020 04:49:45 +0100 Subject: [PATCH 47/49] update README with doc --- README.md | 55 +++++++++++++++++++++++++++++-------------------------- 1 file changed, 29 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 839080f5..ece56e07 100644 --- a/README.md +++ b/README.md @@ -48,11 +48,10 @@ curl https://coronavirus-tracker-api.herokuapp.com/v2/locations | json_pp ### Swagger/OpenAPI -An [interactive SwaggerUI](https://coronavirus-tracker-api.herokuapp.com/) exists for the `v2` endpoints. +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). -Mobile friendly ReDocs can be reached at https://coronavirus-tracker-api.herokuapp.com/docs -The [OpenAPI](https://swagger.io/docs/specification/about/) json can be downloaded at https://coronavirus-tracker-api.herokuapp.com/openapi.json +The [OpenAPI](https://swagger.io/docs/specification/about/) json definition can be downloaded at https://coronavirus-tracker-api.herokuapp.com/openapi.json ## API Endpoints @@ -130,6 +129,7 @@ __Sample response__ "country": "Norway", "country_code": "NO", "province": "", + "county": "", "last_updated": "2020-03-21T06:59:11.315422Z", "coordinates": { }, "latest": { }, @@ -174,6 +174,7 @@ __Sample response__ "country": "Thailand", "country_code": "TH", "province": "", + "county": "", "last_updated": "2020-03-21T06:59:11.315422Z", "coordinates": { "latitude": "15", @@ -190,6 +191,7 @@ __Sample response__ "country": "Norway", "country_code": "NO", "province": "", + "county": "", "last_updated": "2020-03-21T06:59:11.315422Z", "coordinates": { "latitude": "60.472", @@ -239,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 + } + } + ] } ``` From 0e3d352875f1716dcc0932de86a6e300725442a7 Mon Sep 17 00:00:00 2001 From: ExpDev Date: Wed, 25 Mar 2020 04:52:10 +0100 Subject: [PATCH 48/49] small edit in models file --- app/models.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/models.py b/app/models.py index 18ea72f7..05dcaa9c 100644 --- a/app/models.py +++ b/app/models.py @@ -11,18 +11,22 @@ class Latest(BaseModel): 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 @@ -34,11 +38,11 @@ class Location(BaseModel): latest: Latest timelines: Timelines = {} - + class LocationsResponse(BaseModel): latest: Latest locations: List[Location] = [] - + class LocationResponse(BaseModel): location: Location From d7d72d9923d8e2b7f59afa8f7323dde6962dd35a Mon Sep 17 00:00:00 2001 From: ExpDev07 Date: Wed, 25 Mar 2020 04:53:42 +0100 Subject: [PATCH 49/49] add github link --- app/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/main.py b/app/main.py index f384b3ce..076aa551 100644 --- a/app/main.py +++ b/app/main.py @@ -37,7 +37,7 @@ class Sources(str, enum.Enum): APP = fastapi.FastAPI( title='Coronavirus Tracker', - description='API for tracking the global coronavirus (COVID-19, SARS-CoV-2) outbreak.', + 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',