├── reports ├── flake8 │ └── flake8stats.txt └── report_badges │ └── flake8-badge.svg ├── tests ├── test_stackoverflow_badges.py ├── test_validation_error.py ├── test_badge_cache.py └── test_stackoverflow_service.py ├── .gitignore ├── models └── validation_error.py ├── main.py ├── coverage.xml ├── requirements.txt ├── infrastructure └── badge_cache.py ├── api └── stackoverflow_badges.py ├── .github └── workflows │ └── python-tests-action.yml ├── services └── stackoverflow_service.py ├── README.md ├── templates └── badge1.svg └── LICENSE /reports/flake8/flake8stats.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_stackoverflow_badges.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .venv 2 | .vercel 3 | node_modules 4 | __pycache__ 5 | .vscode 6 | .pytest_cache 7 | .coverage -------------------------------------------------------------------------------- /models/validation_error.py: -------------------------------------------------------------------------------- 1 | class ValidationError(Exception): 2 | def __init__(self, error_msg: str, status_code: int): 3 | super().__init__(error_msg) 4 | 5 | self.status_code = status_code 6 | self.error_msg = error_msg 7 | -------------------------------------------------------------------------------- /tests/test_validation_error.py: -------------------------------------------------------------------------------- 1 | from models.validation_error import ValidationError 2 | 3 | 4 | def test_ValidationError(): 5 | ve = ValidationError(error_msg="test", status_code=400) 6 | assert ve.status_code == 400 7 | assert ve.error_msg == "test" 8 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import fastapi 2 | import uvicorn 3 | 4 | from api import stackoverflow_badges 5 | 6 | api = fastapi.FastAPI(docs_url=None) 7 | 8 | 9 | def configure(): 10 | configure_routing() 11 | 12 | 13 | def configure_routing(): 14 | api.include_router(stackoverflow_badges.router) 15 | 16 | 17 | configure() 18 | if __name__ == "__main__": 19 | uvicorn.run(api, port=8000, host="127.0.0.1") 20 | -------------------------------------------------------------------------------- /coverage.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /tests/test_badge_cache.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import pytest 3 | from infrastructure import badge_cache 4 | 5 | def test_create_key(): 6 | # Test valid userID input 7 | userID = '12345' 8 | assert badge_cache.__create_key(userID) == '12345' 9 | 10 | # Test empty userID input 11 | userID = '' 12 | with pytest.raises(Exception) as e: 13 | badge_cache.__create_key(userID) 14 | assert str(e.value) == "userID is required" 15 | 16 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | anyio==3.6.2 2 | attrs==22.1.0 3 | certifi==2023.7.22 4 | charset-normalizer==3.0.1 5 | click==8.1.3 6 | coverage==7.1.0 7 | coverage-badge==1.1.0 8 | defusedxml==0.7.1 9 | exceptiongroup==1.0.1 10 | fastapi==0.97.0 11 | flake8==5.0.4 12 | genbadge==1.1.0 13 | gunicorn==20.1.0 14 | h11==0.12.0 15 | httpcore==0.15.0 16 | httpx==0.23.0 17 | idna==3.4 18 | iniconfig==1.1.1 19 | Jinja2==3.1.2 20 | MarkupSafe==2.1.1 21 | mccabe==0.7.0 22 | packaging==21.3 23 | Pillow==9.4.0 24 | pluggy==1.0.0 25 | pycodestyle==2.9.1 26 | pydantic==1.10.2 27 | pyflakes==2.5.0 28 | pyparsing==3.0.9 29 | pytest==7.2.0 30 | requests==2.28.2 31 | rfc3986==1.5.0 32 | sniffio==1.3.0 33 | starlette==0.27.0 34 | tomli==2.0.1 35 | typing_extensions==4.3.0 36 | urllib3==1.26.14 37 | uvicorn==0.18.2 38 | -------------------------------------------------------------------------------- /reports/report_badges/flake8-badge.svg: -------------------------------------------------------------------------------- 1 | flake8: 0 C, 0 W, 0 Iflake80 C, 0 W, 0 I -------------------------------------------------------------------------------- /infrastructure/badge_cache.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import Optional, Tuple 3 | 4 | __cache = {} 5 | cached_item_lifetime_in_hours = 1.0 6 | 7 | 8 | def get_badge(userID) -> Optional[dict]: 9 | key = __create_key(userID) 10 | data: dict = __cache.get(key) 11 | if not data: 12 | return None 13 | 14 | last = data["time"] 15 | dt = datetime.datetime.now() - last 16 | if dt / datetime.timedelta(minutes=60) < cached_item_lifetime_in_hours: 17 | return data["value"] 18 | 19 | del __cache[key] 20 | return None 21 | 22 | 23 | def set_badge(userID: str, value: dict): 24 | key = __create_key(userID) 25 | data = {"time": datetime.datetime.now(), "value": value} 26 | __cache[key] = data 27 | __clean_out_of_date() 28 | 29 | 30 | def __create_key( 31 | userID: str, 32 | ) -> Tuple[str]: 33 | if not userID: 34 | raise Exception("userID is required") 35 | return userID 36 | 37 | 38 | def __clean_out_of_date(): 39 | for key, data in list(__cache.items()): 40 | dt = datetime.datetime.now() - data.get("time") 41 | if dt / datetime.timedelta(minutes=60) > cached_item_lifetime_in_hours: 42 | del __cache[key] 43 | -------------------------------------------------------------------------------- /api/stackoverflow_badges.py: -------------------------------------------------------------------------------- 1 | import fastapi 2 | import mimetypes 3 | from starlette.templating import Jinja2Templates 4 | from starlette.requests import Request 5 | 6 | from services import stackoverflow_service 7 | from models.validation_error import ValidationError 8 | 9 | mimetypes.init() 10 | 11 | router = fastapi.APIRouter() 12 | templates = Jinja2Templates("./templates") 13 | 14 | 15 | @router.get("/api/StackOverflowBadge/{userID}") 16 | async def StackOverflowBadge(request: Request, userID: str): 17 | try: 18 | data_dict = await stackoverflow_service.StackUserRequestAsync(userID) 19 | except ValidationError as error: 20 | return fastapi.Response( 21 | content=error.error_msg, status_code=error.status_code 22 | ) 23 | except Exception as x: 24 | return fastapi.Response(content=str(x), status_code=500) 25 | mimetypes.add_type("image/svg+xml", ".svg") 26 | return templates.TemplateResponse( 27 | "badge1.svg", 28 | { 29 | "request": request, 30 | "rep": str(data_dict["rep"]), 31 | "gold": str(data_dict["gold"]), 32 | "silver": str(data_dict["silver"]), 33 | "bronze": str(data_dict["bronze"]), 34 | }, 35 | media_type="image/svg+xml", 36 | ) 37 | -------------------------------------------------------------------------------- /.github/workflows/python-tests-action.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: All tests 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Set up Python 3.9 20 | uses: actions/setup-python@v2 21 | with: 22 | python-version: 3.9 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install flake8 pytest 27 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 28 | - name: Lint with flake8 29 | run: | 30 | # stop the build if there are Python syntax errors or undefined names 31 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 32 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 33 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 34 | - name: Test with pytest 35 | run: | 36 | python -m pytest -W ignore::DeprecationWarning 37 | -------------------------------------------------------------------------------- /services/stackoverflow_service.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | from httpx import Response 3 | from infrastructure import badge_cache 4 | 5 | from models.validation_error import ValidationError 6 | 7 | 8 | def validate_input(userID): 9 | if userID.isnumeric(): 10 | return userID 11 | raise ValidationError( 12 | status_code=400, error_msg="userID is invalid. Must be an integer." 13 | ) 14 | 15 | 16 | def format_api_output(response): 17 | """Format retrieved json from API call""" 18 | data = response["items"][0] 19 | return { 20 | "rep": data["reputation"], 21 | "gold": data["badge_counts"]["gold"], 22 | "silver": data["badge_counts"]["silver"], 23 | "bronze": data["badge_counts"]["bronze"], 24 | } 25 | 26 | 27 | async def StackUserRequestAsync(userID: str): 28 | userID = validate_input(userID) 29 | 30 | if badge := badge_cache.get_badge(userID): 31 | return badge 32 | 33 | url = f"https://api.stackexchange.com/2.3/users/{userID}"\ 34 | "?order=desc&sort=reputation&site=stackoverflow"\ 35 | "&filter=!LnOc*f7Nq.zHgKSZ9QN_vj" 36 | 37 | async with httpx.AsyncClient() as client: 38 | resp: Response = await client.get(url) 39 | if resp.status_code != 200: 40 | raise ValidationError(resp.text, status_code=resp.status_code) 41 | 42 | formatted_data = format_api_output(resp.json()) 43 | 44 | badge_cache.set_badge(userID, formatted_data) 45 | 46 | return formatted_data 47 | -------------------------------------------------------------------------------- /tests/test_stackoverflow_service.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import httpx 3 | from services import stackoverflow_service 4 | 5 | 6 | def test_stackoverflow_service_active(): 7 | """Check if URL is still active/up to date""" 8 | assert httpx.get("https://api.stackexchange.com/docs").status_code == 200 9 | 10 | 11 | @pytest.fixture 12 | def user_test_ID(): 13 | return {"incorrectID": "a2d1d2", "correctID": "14122375"} 14 | 15 | 16 | def test_validate_input(user_test_ID): 17 | """Ensure userIDs are numeric strings and fail if not""" 18 | with pytest.raises(Exception): 19 | assert stackoverflow_service.validate_input( 20 | user_test_ID["incorrectID"] 21 | ) 22 | assert ( 23 | stackoverflow_service.validate_input(user_test_ID["correctID"]) 24 | == user_test_ID["correctID"] 25 | ) 26 | 27 | 28 | def test_format_api_output(): 29 | # Test input data 30 | response = { 31 | "items": [ 32 | { 33 | "reputation": 123, 34 | "badge_counts": { 35 | "gold": 3, 36 | "silver": 5, 37 | "bronze": 10, 38 | }, 39 | }, 40 | ], 41 | } 42 | 43 | # Expected output 44 | expected_output = { 45 | "rep": 123, 46 | "gold": 3, 47 | "silver": 5, 48 | "bronze": 10, 49 | } 50 | 51 | # Test function 52 | output = stackoverflow_service.format_api_output(response) 53 | 54 | # Assert output matches expected output 55 | assert output == expected_output 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Pull Requests Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat)](http://makeapullrequest.com) 2 | [![first-timers-only Friendly](https://img.shields.io/badge/first--timers--only-friendly-blue.svg)](http://www.firsttimersonly.com/) 3 | ![example workflow](https://github.com/claytonjhamilton/stackoverflow-badge/actions/workflows/python-tests-action.yml/badge.svg) 4 | [![Flake8 Status](./reports/report_badges/flake8-badge.svg?dummy=8484744)](./reports/flake8/index.html) 5 | 6 |

Display your stats with this unique StackOverflow Badge

7 |

8 | StackOverflow user information 11 |

12 |

Why

13 | This repository is my experiment with setting up an API from scratch and serving data to end users. Along the way I was able to learn how to use GitHub Actions, write simple Python tests, and improve my understanding of FastAPI. 14 |

How to use

15 | Update the following to include your StackOverflow UserID and embed in your GitHub profile's README or other markdown document: 16 | 17 | ``` 18 | [![HamiltonPharmD StackOverflow](https://stackoverflow-badge.onrender.com/api/StackOverflowBadge/14122375)](https://stackoverflow.com/users/14122375/hamiltonpharmd) 19 | ``` 20 | 21 |

Setting up your local environment to contribute

22 | 23 | 1. Find an issue you're interested in resolving 24 | 2. Fork and clone this repo 25 | 3. Create a virtual environment 26 | 4. Run `pip install -r requirements.txt` 27 | 5. Complete code edits 28 | 6. To start the app run `uvicorn main:api` 29 | 7. Open this address in your browser to view the badge: http://127.0.0.1:8000/api/StackOverflowBadge/14122375 30 | 8. Run tests using from project root dir `python -m pytest tests/` and ensure all pass 31 | 9. Update flake8 badge `genbadge flake8 --output-file ./reports/report_badges/flake8-badge.svg` 32 | 10. Submit changes as a PR 33 | -------------------------------------------------------------------------------- /templates/badge1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | REP {{ rep }} 6 | 7 | 8 | 9 | 10 | {{ gold }} 11 | 12 | 13 | 14 | 15 | 16 | 17 | {{ silver }} 18 | 19 | 20 | 21 | 22 | 23 | 24 | {{ bronze }} 25 | 26 | 27 | 28 | 29 | 33 | 37 | 38 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 12 | HEREUNDER. 13 | 14 | Statement of Purpose 15 | 16 | The laws of most jurisdictions throughout the world automatically confer 17 | exclusive Copyright and Related Rights (defined below) upon the creator 18 | and subsequent owner(s) (each and all, an "owner") of an original work of 19 | authorship and/or a database (each, a "Work"). 20 | 21 | Certain owners wish to permanently relinquish those rights to a Work for 22 | the purpose of contributing to a commons of creative, cultural and 23 | scientific works ("Commons") that the public can reliably and without fear 24 | of later claims of infringement build upon, modify, incorporate in other 25 | works, reuse and redistribute as freely as possible in any form whatsoever 26 | and for any purposes, including without limitation commercial purposes. 27 | These owners may contribute to the Commons to promote the ideal of a free 28 | culture and the further production of creative, cultural and scientific 29 | works, or to gain reputation or greater distribution for their Work in 30 | part through the use and efforts of others. 31 | 32 | For these and/or other purposes and motivations, and without any 33 | expectation of additional consideration or compensation, the person 34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she 35 | is an owner of Copyright and Related Rights in the Work, voluntarily 36 | elects to apply CC0 to the Work and publicly distribute the Work under its 37 | terms, with knowledge of his or her Copyright and Related Rights in the 38 | Work and the meaning and intended legal effect of CC0 on those rights. 39 | 40 | 1. Copyright and Related Rights. A Work made available under CC0 may be 41 | protected by copyright and related or neighboring rights ("Copyright and 42 | Related Rights"). Copyright and Related Rights include, but are not 43 | limited to, the following: 44 | 45 | i. the right to reproduce, adapt, distribute, perform, display, 46 | communicate, and translate a Work; 47 | ii. moral rights retained by the original author(s) and/or performer(s); 48 | iii. publicity and privacy rights pertaining to a person's image or 49 | likeness depicted in a Work; 50 | iv. rights protecting against unfair competition in regards to a Work, 51 | subject to the limitations in paragraph 4(a), below; 52 | v. rights protecting the extraction, dissemination, use and reuse of data 53 | in a Work; 54 | vi. database rights (such as those arising under Directive 96/9/EC of the 55 | European Parliament and of the Council of 11 March 1996 on the legal 56 | protection of databases, and under any national implementation 57 | thereof, including any amended or successor version of such 58 | directive); and 59 | vii. other similar, equivalent or corresponding rights throughout the 60 | world based on applicable law or treaty, and any national 61 | implementations thereof. 62 | 63 | 2. Waiver. To the greatest extent permitted by, but not in contravention 64 | of, applicable law, Affirmer hereby overtly, fully, permanently, 65 | irrevocably and unconditionally waives, abandons, and surrenders all of 66 | Affirmer's Copyright and Related Rights and associated claims and causes 67 | of action, whether now known or unknown (including existing as well as 68 | future claims and causes of action), in the Work (i) in all territories 69 | worldwide, (ii) for the maximum duration provided by applicable law or 70 | treaty (including future time extensions), (iii) in any current or future 71 | medium and for any number of copies, and (iv) for any purpose whatsoever, 72 | including without limitation commercial, advertising or promotional 73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each 74 | member of the public at large and to the detriment of Affirmer's heirs and 75 | successors, fully intending that such Waiver shall not be subject to 76 | revocation, rescission, cancellation, termination, or any other legal or 77 | equitable action to disrupt the quiet enjoyment of the Work by the public 78 | as contemplated by Affirmer's express Statement of Purpose. 79 | 80 | 3. Public License Fallback. Should any part of the Waiver for any reason 81 | be judged legally invalid or ineffective under applicable law, then the 82 | Waiver shall be preserved to the maximum extent permitted taking into 83 | account Affirmer's express Statement of Purpose. In addition, to the 84 | extent the Waiver is so judged Affirmer hereby grants to each affected 85 | person a royalty-free, non transferable, non sublicensable, non exclusive, 86 | irrevocable and unconditional license to exercise Affirmer's Copyright and 87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the 88 | maximum duration provided by applicable law or treaty (including future 89 | time extensions), (iii) in any current or future medium and for any number 90 | of copies, and (iv) for any purpose whatsoever, including without 91 | limitation commercial, advertising or promotional purposes (the 92 | "License"). The License shall be deemed effective as of the date CC0 was 93 | applied by Affirmer to the Work. Should any part of the License for any 94 | reason be judged legally invalid or ineffective under applicable law, such 95 | partial invalidity or ineffectiveness shall not invalidate the remainder 96 | of the License, and in such case Affirmer hereby affirms that he or she 97 | will not (i) exercise any of his or her remaining Copyright and Related 98 | Rights in the Work or (ii) assert any associated claims and causes of 99 | action with respect to the Work, in either case contrary to Affirmer's 100 | express Statement of Purpose. 101 | 102 | 4. Limitations and Disclaimers. 103 | 104 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 105 | surrendered, licensed or otherwise affected by this document. 106 | b. Affirmer offers the Work as-is and makes no representations or 107 | warranties of any kind concerning the Work, express, implied, 108 | statutory or otherwise, including without limitation warranties of 109 | title, merchantability, fitness for a particular purpose, non 110 | infringement, or the absence of latent or other defects, accuracy, or 111 | the present or absence of errors, whether or not discoverable, all to 112 | the greatest extent permissible under applicable law. 113 | c. Affirmer disclaims responsibility for clearing rights of other persons 114 | that may apply to the Work or any use thereof, including without 115 | limitation any person's Copyright and Related Rights in the Work. 116 | Further, Affirmer disclaims responsibility for obtaining any necessary 117 | consents, permissions or other rights required for any use of the 118 | Work. 119 | d. Affirmer understands and acknowledges that Creative Commons is not a 120 | party to this document and has no duty or obligation with respect to 121 | this CC0 or use of the Work. 122 | --------------------------------------------------------------------------------