Merge pull request 'poetry-migration' (#2) from poetry-migration into main

Reviewed-on: #2
This commit is contained in:
Lucas Jensen
2024-06-29 21:40:08 +00:00
14 changed files with 1380 additions and 351 deletions

View File

@@ -12,18 +12,21 @@ jobs:
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Set up Python 3.11 - name: Set up Python 3.10
uses: actions/setup-python@v3 uses: actions/setup-python@v3
with: with:
python-version: "3.11" python-version: "3.10"
- name: Install poetry
uses: abatilo/actions-poetry@v2
- name: Install dependencies - name: Install dependencies
working-directory: server working-directory: server
run: | run: |
python -m pip install --upgrade pip poetry install
pip install pytest
pip install -r requirements.txt
- name: Lint with black - name: Lint with black
working-directory: server working-directory: server
run: | run: |
black --check . ls -al
poetry run black --check .

View File

@@ -1,12 +1,12 @@
{ {
"name": "portfolio-front", "name": "portfolio-front",
"version": "0.0.1", "version": "0.0.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "portfolio-front", "name": "portfolio-front",
"version": "0.0.1", "version": "0.0.7",
"dependencies": { "dependencies": {
"@auth0/auth0-react": "^2.2.4", "@auth0/auth0-react": "^2.2.4",
"@fortawesome/fontawesome-svg-core": "^6.5.1", "@fortawesome/fontawesome-svg-core": "^6.5.1",

2
server/.gitignore vendored
View File

@@ -174,3 +174,5 @@ poetry.toml
pyrightconfig.json pyrightconfig.json
# End of https://www.toptal.com/developers/gitignore/api/python # End of https://www.toptal.com/developers/gitignore/api/python
.vscode/

View File

@@ -1 +0,0 @@
__version__ = "0.1.22"

View File

@@ -5,24 +5,21 @@ CREATE TABLE `self` (
`email` VARCHAR(255) NOT NULL, `email` VARCHAR(255) NOT NULL,
`bio` TEXT NOT NULL, `bio` TEXT NOT NULL,
`github` VARCHAR(255) NOT NULL, `github` VARCHAR(255) NOT NULL,
`auth0_sub` VARCHAR(255) NOT NULL, `gitea` VARCHAR(255) NOT NULL
`test_sub` VARCHAR(255) NOT NULL
); );
INSERT INTO `self` ( INSERT INTO `self` (
`name`, `name`,
`email`, `email`,
`bio`, `bio`,
`github`, `github`,
`auth0_sub`, `gitea`
`test_sub`
) )
VALUES ( VALUES (
'Lucas Jensen', 'Lucas Jensen',
'lucas.p.jensen10@gmail.com', 'lucas@lucasjensen.me',
"I am a recent graduate from Oregon State University with a Bachelor's degree in Computer Science, driven by a passion for solving complex problems through technology. During my academic journey, I honed my skills and practical knowledge, setting a strong foundation for my career. My enthusiasm led me to a Software Engineering internship at Cvent, where I focused on Service Level Indicators (SLIs) and TypeScript. This experience allowed me to dive deep into the intricacies of backend development, gaining hands-on expertise in Python, FastAPI, Flask, Bash scripting, Linux, Nginx, and Systemd.\nMy commitment to delivering robust solutions is reflected in my proficiency in writing unit tests, ensuring the reliability and stability of the software I develop. I thrive in collaborative environments and have successfully contributed to team projects, understanding the importance of effective communication and cooperation. As I embark on my professional journey, I am excited to leverage my diverse skill set to tackle new challenges and make meaningful contributions to the field of computer science. Explore my portfolio to witness the intersection of my academic background and practical experiences that shape my identity as a dedicated and skilled computer scientist.", "I'm a software engineer working for the University of Oregon and an alum from Oregon State University with a B.S. in Computer Science. I'm passionate about open source software and self hosting anything and everything. Most of my projects, including this gitea instance, are hosted either on a Raspberry Pi or an Ubuntu server with Linode.\nMuch of what you see here is a WIP and will likely remain so for the foreseeable future. All the projects listed here are passion projects which are tended to in my spare time.",
'https://github.com/ljensen505', 'https://github.com/ljensen505',
'google-oauth2|103593642272149633528', 'https://gitea.lucasjensen.me/lucasjensen'
'FZdDeArr7QuX8qVmbKD2ggdLvlJZKEjE@clients'
); );
DROP TABLE IF EXISTS `projects`; DROP TABLE IF EXISTS `projects`;
CREATE TABLE `projects` ( CREATE TABLE `projects` (
@@ -43,22 +40,22 @@ INSERT INTO `projects` (
VALUES ( VALUES (
'The Grapefruits Duo', 'The Grapefruits Duo',
'An artist website for a local chamber music duo. Built with MySQL, FastAPI, and React with TypeScript.', 'An artist website for a local chamber music duo. Built with MySQL, FastAPI, and React with TypeScript.',
'https://github.com/ljensen505/TheGrapefruitsDuo', 'https://gitea.lucasjensen.me/lucasjensen/TheGrapefruitsDuo',
'https://thegrapefruitsduo.com/', 'https://thegrapefruitsduo.com/',
TRUE TRUE
), ),
( (
'Portfolio Backend', 'Portfolio Homepage',
'A RESTful API for my portfolio website. Consumed by the portfolio frontend. Built with FastAPI and MySQL. Hosted on a Raspberry Pi in my living room.', 'This very website!',
'https://github.com/ljensen505/portfolio-back', 'https://gitea.lucasjensen.me/lucasjensen/LucasJensen',
'https://api.lucasjensen.me/', 'https://lucasjensen.me/',
TRUE TRUE
), ),
( (
'Portfolio Frontend', 'Megan Johns Portfilo Website',
'The frontend for my portfolio website (this very site!). Consumes the portfolio backend. Built with React and Typescript. Hosted on a Raspberry Pi in my living room.', 'A comprehensive portfolio for local Eugene musician and artist, Megan Johns.',
'https://github.com/ljensen505/portfolio-front', 'https://gitea.lucasjensen.me/lucasjensen/MeganJohns',
'https://lucasjensen.me/', 'https://mj.lucasjensen.me/',
TRUE TRUE
), ),
( (
@@ -74,18 +71,4 @@ VALUES (
NULL, NULL,
'https://efdl.lucasjensen.me/', 'https://efdl.lucasjensen.me/',
TRUE TRUE
),
(
'Chess API',
'A RESTful API for playing chess online. Consumed by the Chess GUI.',
'https://github.com/ljensen505/chess-back',
'https://api.chess.v2.lucasjensen.me/',
TRUE
),
(
'Chess',
'A webapp for playing chess online against a friend. Consumes the Chess API.',
'https://github.com/ljensen505/chess-front',
'https://chess.lucasjensen.me/',
FALSE
) )

View File

@@ -6,6 +6,7 @@ origins = [
"http://localhost", "http://localhost",
"http://localhost:3000", "http://localhost:3000",
"https://localhost:3000", "https://localhost:3000",
"http://127.0.0.1:3000",
"https://lucasjensen.me/", "https://lucasjensen.me/",
"https://lucasjensen.me", "https://lucasjensen.me",
"https://www.lucasjensen.me/", "https://www.lucasjensen.me/",

View File

@@ -6,14 +6,11 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
import queries import queries
from __version__ import __version__
from helpers import origins from helpers import origins
from models import About, Project from models import About, Project, Lucas
from utils import VerifyToken
load_dotenv() load_dotenv()
app = FastAPI() app = FastAPI()
auth = VerifyToken()
app.add_middleware( app.add_middleware(
@@ -28,19 +25,9 @@ app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/", status_code=status.HTTP_200_OK) @app.get("/", status_code=status.HTTP_200_OK)
async def root(): async def root() -> Lucas:
available_routes = [ lucas = Lucas(about=queries.get_about(), projects=queries.get_projects())
"/", return lucas
"/about",
"/projects",
"/static/resume.pdf",
"/static/favicon.png",
]
return {
"welcome": "backend api for lucasjensen.me",
"version": __version__,
"routes": available_routes,
}
@app.get("/about", status_code=status.HTTP_200_OK) @app.get("/about", status_code=status.HTTP_200_OK)
@@ -69,11 +56,9 @@ async def projects() -> list[Project]:
@app.get("/projects/{project_id}", status_code=status.HTTP_200_OK) @app.get("/projects/{project_id}", status_code=status.HTTP_200_OK)
async def project(project_id: int) -> Project: async def project(project_id: int) -> Project:
project = queries.get_project(project_id) project = None
try: try:
project = queries.get_project(project_id) project = queries.get_project(project_id)
except Exception as e: except Exception as e:
print(f"err getting projects: {e}") print(f"err getting projects: {e}")
raise HTTPException( raise HTTPException(
@@ -86,48 +71,3 @@ async def project(project_id: int) -> Project:
detail=f"project with id {project_id} not found", detail=f"project with id {project_id} not found",
) )
return project return project
@app.post("/projects", status_code=status.HTTP_201_CREATED)
async def post_project(project: Project, auth_result=Security(auth.verify)) -> Project:
user_sub, test_sub = queries.get_subs().values()
jwt_sub = auth_result.get("sub")
if jwt_sub not in [user_sub, test_sub]:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="unauthorized",
)
try:
return queries.create_project(project)
except Exception as e:
print(f"err creating project: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"database error: {e}",
)
@app.delete("/projects/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_project(project_id: int, auth_result=Security(auth.verify)):
user_sub, test_sub = queries.get_subs().values()
jwt_sub = auth_result.get("sub")
if jwt_sub not in [user_sub, test_sub]:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="unauthorized",
)
project = queries.get_project(project_id)
if project is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"project with id {project_id} not found",
)
try:
return queries.delete_project(project_id)
except Exception as e:
print(f"err deleting project: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"database error: {e}",
)

View File

@@ -1,4 +1,5 @@
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional
class About(BaseModel): class About(BaseModel):
@@ -6,12 +7,18 @@ class About(BaseModel):
email: str email: str
bio: str bio: str
github: str github: str
gitea: str
class Project(BaseModel): class Project(BaseModel):
id: int | None = None id: Optional[int] = None
name: str name: str
description: str description: str
source: str | None = None source: Optional[str] = None
live: str | None = None live: Optional[str] = None
is_self_hosted: bool = False is_self_hosted: Optional[bool] = False
class Lucas(BaseModel):
projects: list[Project]
about: About

1312
server/poetry.lock generated Normal file

File diff suppressed because it is too large Load Diff

22
server/pyproject.toml Normal file
View File

@@ -0,0 +1,22 @@
[tool.poetry]
name = "lucasjensen-fastapi"
version = "0.1.0"
description = "A RESTful API for lucasjensen.me"
authors = ["Lucas Jensen <lucas@lucasjensen.me>"]
readme = "README.md"
package-mode = false
[tool.poetry.dependencies]
python = "^3.10"
black = "^24.4.2"
pytest = "^8.2.2"
fastapi = "^0.111.0"
mysql-connector-python = "^8.4.0"
pyjwt = "^2.8.0"
pydantic = "^2.7.4"
pydantic-settings = "^2.3.4"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

View File

@@ -52,7 +52,7 @@ def delete_project(project_id: int) -> None:
def get_about() -> About: def get_about() -> About:
db = connect_db() db = connect_db()
cursor = db.cursor(dictionary=True) cursor = db.cursor(dictionary=True)
cursor.execute("SELECT name, email, bio, github FROM self") cursor.execute("SELECT name, email, bio, github, gitea FROM self")
data = {key: val for key, val in cursor.fetchone().items()} # type: ignore data = {key: val for key, val in cursor.fetchone().items()} # type: ignore
db.close() db.close()
return About(**data) return About(**data)

View File

@@ -1,47 +0,0 @@
annotated-types==0.6.0
anyio==4.2.0
black==23.12.1
certifi==2023.11.17
cffi==1.16.0
charset-normalizer==3.3.2
click==8.1.7
cryptography==41.0.7
dotted-notation==0.11.0
fastapi==0.108.0
h11==0.14.0
httpcore==1.0.2
httptools==0.6.1
httpx==0.26.0
idna==3.6
iniconfig==2.0.0
markdown-it-py==3.0.0
mdurl==0.1.2
mypy-extensions==1.0.0
mysql-connector-python==8.2.0
packaging==23.2
pathspec==0.12.1
platformdirs==4.1.0
pluggy==1.3.0
protobuf==4.21.12
pycparser==2.21
pydantic==2.5.3
pydantic-settings==2.1.0
pydantic_core==2.14.6
Pygments==2.17.2
PyJWT==2.8.0
pyparsing==3.1.1
pytest==7.4.4
python-dotenv==1.0.0
PyYAML==6.0.1
requests==2.31.0
rich==13.7.0
rich-click==1.7.2
sniffio==1.3.0
starlette==0.32.0.post1
tomlkit==0.12.3
typing_extensions==4.9.0
urllib3==2.1.0
uvicorn==0.25.0
uvloop==0.19.0
watchfiles==0.21.0
websockets==12.0

View File

@@ -1,123 +0,0 @@
from fastapi.testclient import TestClient
from helpers import get_token
from main import app
client = TestClient(app)
token = get_token()
def test_root():
response = client.get("/")
assert response.status_code == 200
body: dict[str, str] = response.json()
welcome = body["welcome"]
version = body["version"]
major, minor, patch = version.split(".")
routes = body["routes"]
assert welcome == "backend api for lucasjensen.me"
for v in [major, minor, patch]:
assert v.isnumeric()
assert len(routes) >= 3
def test_about():
response = client.get("/about")
assert response.status_code == 200
body = response.json()
vals = ["name", "email", "bio", "github"]
assert all([k in body for k in vals])
def test_projects():
response = client.get("/projects")
assert response.status_code == 200
body = response.json()
assert len(body) > 0
vals = ["id", "name", "description"] # remaining vals are optional
assert all([k in body[0] for k in vals])
def test_project():
response = client.get("/projects/1")
assert response.status_code == 200
body = response.json()
vals = ["id", "name", "description"] # remaining vals are optional
assert all([k in body for k in vals])
def test_post_projects():
p_id = post_project()
client.delete(
f"/projects/{p_id}",
headers={"Authorization": f"Bearer {token}"},
)
def delete_project(p_id: int):
response = client.delete(
f"/projects/{p_id}",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 204
response = client.get("/projects")
assert response.status_code == 200
body = response.json()
assert not any([p.get("id") == p_id for p in body])
def post_project() -> int:
project = {
"name": "test project",
"description": "test description",
"source": "github.com/test",
"live": "test.com",
"is_self_hosted": False,
}
response = client.post(
"/projects",
json=project,
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 201
p_id = int(response.json()["id"])
assert isinstance(p_id, int)
response = client.get("/projects")
assert response.status_code == 200
body = response.json()
assert any([p.get("id") == p_id for p in body])
response = client.get(f"/projects/{p_id}")
assert response.status_code == 200
body = response.json()
assert body["name"] == project["name"]
assert body["description"] == project["description"]
assert body["source"] == project["source"]
assert body["live"] == project["live"]
assert body["id"] == p_id
return p_id
def test_delete_project():
p_id = post_project()
all_projects = client.get("/projects").json()
assert any([p.get("id") == p_id for p in all_projects])
delete_project(p_id)
all_projects = client.get("/projects").json()
assert not any([p.get("id") == p_id for p in all_projects])
def test_get_static_file():
response = client.get("/static/resume.pdf")
assert response.status_code == 200
assert response.headers["Content-Type"] == "application/pdf"
response = client.get("/static/favicon.png")
assert response.status_code == 200
assert response.headers["Content-Type"] == "image/png"

View File

@@ -1,70 +0,0 @@
from typing import Optional
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer, SecurityScopes
from config import get_settings
class UnauthorizedException(HTTPException):
def __init__(self, detail: str, **kwargs):
"""Returns HTTP 403"""
super().__init__(status.HTTP_403_FORBIDDEN, detail=detail)
class UnauthenticatedException(HTTPException):
def __init__(self):
super().__init__(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Requires authentication",
)
class UnauthenticatedException(HTTPException):
def __init__(self):
super().__init__(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Requires authentication"
)
class VerifyToken:
"""Does all the token verification using PyJWT"""
def __init__(self):
self.config = get_settings()
# This gets the JWKS from a given URL and does processing so you can
# use any of the keys available
jwks_url = f"https://{self.config.auth0_domain}/.well-known/jwks.json"
self.jwks_client = jwt.PyJWKClient(jwks_url)
async def verify(
self,
security_scopes: SecurityScopes,
token: Optional[HTTPAuthorizationCredentials] = Depends(HTTPBearer()),
):
if token is None:
raise UnauthenticatedException
# This gets the 'kid' from the passed token
try:
signing_key = self.jwks_client.get_signing_key_from_jwt(
token.credentials
).key
except jwt.exceptions.PyJWKClientError as error:
raise UnauthorizedException(str(error))
except jwt.exceptions.DecodeError as error:
raise UnauthorizedException(str(error))
try:
payload = jwt.decode(
token.credentials,
signing_key,
algorithms=self.config.auth0_algorithms, # type: ignore
audience=self.config.auth0_api_audience,
issuer=self.config.auth0_issuer,
)
except Exception as error:
raise UnauthorizedException(str(error))
return payload