Skip to content

Commit a7727e4

Browse files
committed
#2 Implement Score API (done) [Implement main score API]
1 parent 58bcda0 commit a7727e4

16 files changed

+383
-34
lines changed

ReadMe.rst

Lines changed: 83 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,54 @@
1+
Intro
2+
=====
3+
4+
This is a Github Score Application to rank repositories based on forks and starts.
5+
6+
7+
Stack
8+
=====
9+
10+
In this application we used:
11+
12+
* Python
13+
* FastAPI
14+
15+
16+
117
Install requirements
218
====================
319

4-
$ pip install -r requirements.txt
20+
```bash
21+
22+
$ pip install -r requirements.txt
23+
24+
```
25+
26+
Environments
27+
============
528

29+
```bash
30+
31+
$ export GITHUB_ACCESS_TOKEN="YOUR_GITHUB_ACCESS_TOKEN"
32+
33+
```
634

735
Run server
836
==========
937

10-
$ uvicorn main:app
38+
```bash
39+
40+
$ uvicorn main:app
1141
42+
```
1243

1344
Run tests
1445
=========
1546

16-
$ pytest
47+
```bash
48+
49+
$ pytest
50+
51+
```
1752

1853
Browse
1954
======
@@ -23,9 +58,51 @@ Browse
2358
http://127.0.0.1:8000/score/
2459

2560

26-
Knowledge links
27-
===============
61+
Curl
62+
====
63+
64+
```bash
65+
66+
$ curl -X 'POST' 'http://127.0.0.1:8000/score/' -H 'accept: application/json' -H 'Content-Type: application/json' -d '{"repo":"python/cpython"}
67+
68+
```
69+
70+
TODO
71+
====
72+
73+
Cache external service API response for some short period of time for example for 1 hour in the Redis,
74+
And maybe provide a new cache option for example `Cache-Control: no-cache` header to the `score` API
75+
2876

77+
Useful links
78+
============
2979

30-
https://fastapi.tiangolo.com/tutorial/metadata/
3180

81+
* https://fastapi.tiangolo.com/tutorial/metadata/
82+
* https://levelup.gitconnected.com/deploying-an-asynchronous-fastapi-on-nginx-unit-b038288bec5
83+
* https://dev.to/shuv1824/deploy-fastapi-application-on-ubuntu-with-nginx-gunicorn-and-uvicorn-3mbl
84+
* https://medium.com/analytics-vidhya/how-to-deploy-a-python-api-with-fastapi-with-nginx-and-docker-1328cbf41bc
85+
* https://stackoverflow.com/questions/67435296/how-to-access-fastapi-swaggerui-docs-behind-an-nginx-proxy
86+
* https://fastapi.tiangolo.com/advanced/behind-a-proxy/
87+
* https://www.reddit.com/r/flask/comments/j2289k/anyone_using_fastapi_in_production/
88+
* https://stackoverflow.com/questions/62976648/architecture-flask-vs-fastapi/62977786#62977786
89+
* https://github.com/tiangolo/uvicorn-gunicorn-fastapi-docker
90+
* https://fastapi.tiangolo.com/deployment/manually/
91+
* https://unit.nginx.orga
92+
* https://www.uvicorn.org/#running-with-gunicorn
93+
* https://github.com/encode/uvicorn
94+
* https://towardsdatascience.com/how-to-deploy-a-machine-learning-model-with-fastapi-docker-and-github-actions-13374cbd638a
95+
* https://fastapi.tiangolo.com/tutorial/first-steps/
96+
* https://unit.nginx.org/
97+
* https://fastapi.tiangolo.com/advanced/behind-a-proxy/
98+
* https://fastapi.tiangolo.com/deployment/manually/
99+
* https://www.uvicorn.org/#running-with-gunicorn
100+
* https://github.com/tiangolo/uvicorn-gunicorn-fastapi-docker
101+
* https://stackoverflow.com/a/63427961
102+
* https://stackoverflow.com/a/66426744
103+
* https://levelup.gitconnected.com/deploying-an-asynchronous-fastapi-on-nginx-unit-b038288bec5
104+
* https://dev.to/shuv1824/deploy-fastapi-application-on-ubuntu-with-nginx-gunicorn-and-uvicorn-3mbl
105+
* https://github.com/tiangolo/fastapi/issues/1034#issuecomment-591651300
106+
* https://fastapi.tiangolo.com/tutorial/body/
107+
* https://fastapi.tiangolo.com/tutorial/handling-errors/
108+
* https://gist.github.com/omidraha/72817ed0c6173f6c47613e3eebf03ad7

apps/__init__.py

Whitespace-only changes.

apps/score/__init__.py

Whitespace-only changes.

apps/score/serializer.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# noinspection PyPackageRequirements
2+
from pydantic import BaseModel
3+
4+
5+
class ScoreRequest(BaseModel):
6+
"""
7+
This class define Request data model for score API
8+
"""
9+
repo: str
10+
11+
12+
class ScoreResponse(BaseModel):
13+
"""
14+
This class define Response data model for score API
15+
"""
16+
repo: str
17+
score: int
18+
19+
20+
class ScoreExceptionResponse(BaseModel):
21+
"""
22+
This class define Response data model for score exception API
23+
"""
24+
message: str

apps/score/view.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from fastapi import APIRouter, HTTPException, status
2+
3+
from apps.score.serializer import ScoreResponse, ScoreRequest, ScoreExceptionResponse
4+
from settings import GITHUB_ACCESS_TOKEN
5+
from utility.exception import AppException
6+
from utility.github_api import GitHubAPI
7+
8+
router = APIRouter()
9+
10+
TAG_SCORE = {
11+
"name": "score",
12+
"description": "The main API to score",
13+
}
14+
15+
16+
@router.post("/score/",
17+
# Hint the API docs by providing following info
18+
tags=["score"],
19+
response_model=ScoreResponse,
20+
responses={
21+
status.HTTP_200_OK: {"model": ScoreResponse},
22+
status.HTTP_404_NOT_FOUND: {"model": ScoreExceptionResponse},
23+
status.HTTP_503_SERVICE_UNAVAILABLE: {"model": ScoreExceptionResponse}})
24+
async def score(data: ScoreRequest):
25+
"""
26+
*Example:*
27+
28+
curl -X 'POST' 'http://127.0.0.1:8000/score/' \
29+
-H 'accept: application/json' \
30+
-H 'Content-Type: application/json' \
31+
-d '{"repo":"python/cpython"}'
32+
"""
33+
# @note: This may be raise ServiceException that handled globally
34+
g = GitHubAPI(token=GITHUB_ACCESS_TOKEN)
35+
try:
36+
repo = g.get_repo(data.repo)
37+
except AppException as e:
38+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
39+
detail=e.message)
40+
else:
41+
return ScoreResponse(repo=repo.full_name,
42+
score=repo.score)

main.py

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
1-
from fastapi import FastAPI
1+
from fastapi import FastAPI, Request, status
2+
from fastapi.responses import JSONResponse
3+
# noinspection PyPackageRequirements
4+
from starlette.exceptions import HTTPException as StarletteHTTPException
5+
6+
import apps.score.view
7+
from utility.exception import ServiceException
28

39
description = """
410
## Description
511
Github Score Application to rank repositories. 🚀
612
## Available APIs
7-
The following is a list of available APIs.
13+
The following is an list of available APIs.
814
"""
915

1016
tags_metadata = [
11-
{
12-
"name": "score",
13-
"description": "The main API to score",
14-
},
17+
apps.score.view.TAG_SCORE
1518
]
1619

1720
app = FastAPI(
@@ -22,14 +25,22 @@
2225
)
2326

2427

25-
@app.get("/score/", tags=["score"])
26-
async def score():
27-
"""
28-
*Example:*
28+
# Define ServiceException as global exception handler
29+
@app.exception_handler(ServiceException)
30+
async def service_exception_handler(request: Request, exc: ServiceException):
31+
return JSONResponse(
32+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
33+
content={"message": exc.message, },
34+
)
35+
2936

30-
curl -X 'GET' 'http://127.0.0.1:8000/score/' -H 'accept: application/json'
31-
"""
32-
# @todo: Implement real score API
33-
return {"score": 100}
37+
# Customize default HTTPException
38+
@app.exception_handler(StarletteHTTPException)
39+
async def http_exception_handler(request, exc):
40+
return JSONResponse(
41+
status_code=exc.status_code,
42+
content={"message": exc.detail, },
43+
)
3444

3545

46+
app.include_router(apps.score.view.router)

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ pytest==6.2.5
22
fastapi==0.68.1
33
uvicorn==0.15.0
44
requests==2.26.0
5+
PyGithub==1.55

settings.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import os
2+
# @note: To use this this service, we should set this variable
3+
GITHUB_ACCESS_TOKEN = os.environ.get('GITHUB_ACCESS_TOKEN')
4+

test_main.py

Lines changed: 0 additions & 14 deletions
This file was deleted.

tests/__init__.py

Whitespace-only changes.

tests/test_github_api.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
from typing import Optional
2+
3+
import pytest
4+
from fastapi.testclient import TestClient
5+
6+
import settings
7+
from main import app
8+
from utility.exception import InvalidToken, RepoIsNotExist, EmptyToken
9+
from utility.github_api import GitHubAPI
10+
11+
client = TestClient(app)
12+
13+
14+
@pytest.fixture(scope='class')
15+
def github_api():
16+
return GitHubAPI(token=settings.GITHUB_ACCESS_TOKEN)
17+
18+
19+
@pytest.fixture(scope='class')
20+
def python_repo(github_api):
21+
return github_api.get_repo('python/cpython')
22+
23+
24+
class TestGitHubAPI:
25+
"""
26+
Test GitHubAPI class
27+
"""
28+
29+
def test_empty_token(self):
30+
with pytest.raises(EmptyToken):
31+
# noinspection PyTypeChecker
32+
GitHubAPI(token=None)
33+
34+
def test_invalid_token(self):
35+
g = GitHubAPI(token='invalid_token')
36+
with pytest.raises(InvalidToken):
37+
g.get_repo('python/cpython')
38+
39+
def test_invalid_repo(self, github_api):
40+
with pytest.raises(RepoIsNotExist):
41+
github_api.get_repo('invalid_repo/not_exist')
42+
43+
44+
class TestRepository:
45+
"""
46+
Test Repository class
47+
"""
48+
49+
def test_get_repo(self, python_repo):
50+
assert python_repo.name == 'cpython'
51+
assert python_repo.full_name == 'python/cpython'
52+
53+
def test_forks(self, python_repo):
54+
# @Note: because forks dynamically increase over the time
55+
assert python_repo.forks >= 20259
56+
57+
def test_stars(self, python_repo):
58+
# @Note: because stars dynamically increase over the time
59+
assert python_repo.stars >= 40454
60+
61+
def test_score(self, python_repo):
62+
# @Note: because score dynamically increase over the time
63+
assert python_repo.score >= 80972

tests/test_score_view.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from fastapi.testclient import TestClient
2+
from fastapi import status
3+
4+
from main import app
5+
from utility.exception import RepoIsNotExist
6+
7+
client = TestClient(app)
8+
9+
10+
class TestScoreView:
11+
"""
12+
Test `score` view API
13+
"""
14+
15+
def test_score_api_valid_repo(self):
16+
"""
17+
Test `score` API while repo is valid
18+
"""
19+
response = client.post("/score/",
20+
json={'repo': 'python/cpython', })
21+
22+
assert response.status_code == status.HTTP_200_OK
23+
# @Note: because score dynamically increase over the time
24+
assert response.json()["score"] >= 80972
25+
assert response.json()["repo"] == 'python/cpython'
26+
27+
def test_score_api_invalid_repo(self):
28+
"""
29+
Test `score` API while repo is not valid
30+
"""
31+
response = client.post("/score/",
32+
json={'repo': 'invalid_repo/not_exist', })
33+
34+
assert response.status_code == status.HTTP_404_NOT_FOUND
35+
assert response.json()["message"] == RepoIsNotExist.message

tests/test_util.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from fastapi.testclient import TestClient
2+
3+
from main import app
4+
from utility.util import cal_score
5+
6+
client = TestClient(app)
7+
8+
9+
def test_cal_score():
10+
"""
11+
Test `cal_score` util function
12+
"""
13+
score = cal_score(num_stars=100, num_forks=200)
14+
assert score == 500

0 commit comments

Comments
 (0)