This commit is contained in:
2019-10-30 14:45:02 +01:00
commit 892c80861c
10 changed files with 628 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
Pipfile
codealike.json

0
app/__init__.py Normal file
View File

72
app/crud.py Normal file
View File

@@ -0,0 +1,72 @@
from pydantic import BaseModel
from sqlalchemy.orm import Session
import app.models
from app.database import Base
from . import schemas
class DoesntExist(Exception):
pass
class ArgumentError(Exception):
pass
def create_thing(session: Session, model: Base, data: BaseModel):
instance = model(**data.dict())
session.add(instance)
session.commit()
session.refresh(instance)
return instance
def get_thing(session: Session, model: Base, thing_id: int = None, multi=False, **kwargs):
if thing_id and multi:
raise ArgumentError
if thing_id:
return session.query(model).filter_by(id=thing_id).first()
elif kwargs:
return session.query(model).filter_by(**kwargs).first()
else:
if multi:
return session.query(model).all()
else:
return session.query(model).first()
def update_thing(db: Session,
model: Base,
schema: BaseModel,
thing_id: int,
**kwargs
) -> BaseModel:
if not kwargs:
raise ArgumentError
if any(kwarg_key not in schema.__fields__.keys() for kwarg_key in kwargs):
raise ArgumentError
thing = get_thing(db, model, thing_id=thing_id)
if thing is None:
raise DoesntExist
for key, value in kwargs.items():
setattr(thing, key, value)
db.commit()
db.refresh(thing)
return schema(**thing.to_dict())
def delete_thing(db: Session,
model: Base,
thing_id: int):
thing = get_thing(db, model=model, thing_id=thing_id)
if not thing:
raise DoesntExist
else:
db.delete(thing)
db.commit()

9
app/database.py Normal file
View File

@@ -0,0 +1,9 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from config import SQLALCHEMY_DATABASE_URL
engine = create_engine(SQLALCHEMY_DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()

153
app/main.py Normal file
View File

@@ -0,0 +1,153 @@
from typing import List
from fastapi import FastAPI, Depends, HTTPException
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from sqlalchemy.orm import Session
from starlette.requests import Request
from starlette.status import HTTP_401_UNAUTHORIZED, HTTP_409_CONFLICT
from app import schemas, models
from config import PERSONAL_API_USERNAME, PERSONAL_API_PASS
from .crud import (
create_thing as create, get_thing as get, update_thing as update, DoesntExist,
delete_thing as delete,
)
from .database import SessionLocal, Base, engine
Base.metadata.create_all(bind=engine)
app = FastAPI()
security = HTTPBasic()
def get_db():
try:
db = SessionLocal()
yield db
finally:
# noinspection PyUnboundLocalVariable
db.close()
def get_current_username(credentials: HTTPBasicCredentials = Depends(security)):
if credentials.username != PERSONAL_API_USERNAME or credentials.password != PERSONAL_API_PASS:
raise HTTPException(
status_code=HTTP_401_UNAUTHORIZED,
detail="Forbidden",
headers={"WWW-Authenticate": "Basic"},
)
return credentials.username
@app.get("/")
def read_root(_: str = Depends(get_current_username)):
return {"Hello": "World"}
@app.get("/availability/", response_model=schemas.Availability)
def get_availability(db: Session = Depends(get_db), _: str = Depends(get_current_username)):
return get(db, models.Availability)
@app.get("/resume/", response_model=schemas.Resume)
def get_resume(db: Session = Depends(get_db), _: str = Depends(get_current_username)):
return get(db, models.Resume)
@app.get("/projects/", response_model=List[schemas.Project])
def get_projects(db: Session = Depends(get_db), _: str = Depends(get_current_username)):
return get(db, models.Project, multi=True)
@app.get("/projects/{project_id}", response_model=schemas.Project)
def get_projects(project_id: int, db: Session = Depends(get_db), _: str = Depends(get_current_username)):
return get(db, models.Project, thing_id=project_id)
@app.get("/posts/", response_model=List[schemas.Post])
def get_posts(db: Session = Depends(get_db), _: str = Depends(get_current_username)):
return get(db, models.Post, multi=True)
@app.get("/posts/{post_id}", response_model=schemas.Post)
def get_post(post_id: int, db: Session = Depends(get_db), _: str = Depends(get_current_username)):
return get(db, models.Post, post_id)
@app.post("/posts/", response_model=schemas.Post)
def post_post(
post_: schemas.PostCreate,
db: Session = Depends(get_db),
_: str = Depends(get_current_username)
):
if get(db, model=models.Post, title=post_.title):
raise HTTPException(status_code=409)
return create(db, model=models.Post, data=post_)
@app.patch("/posts/{post_id}")
async def patch_project(post_id: int,
request: Request,
db: Session = Depends(get_db),
_: str = Depends(get_current_username),
):
return update(db, model=models.Post, schema=schemas.PostCreate, thing_id=post_id, **await request.json())
@app.post("/availability/", response_model=schemas.Availability)
def post_availability(
availability: schemas.AvailabilityCreate,
db: Session = Depends(get_db),
_: str = Depends(get_current_username)
):
return create(db, model=models.Availability, data=availability)
@app.post("/resume/", response_model=schemas.Resume)
def post_resume(
resume: schemas.ResumeCreate,
db: Session = Depends(get_db),
_: str = Depends(get_current_username)
):
return create(db, model=models.Resume, data=resume)
@app.post("/projects/", response_model=schemas.Project)
def post_project(
project: schemas.ProjectCreate,
db: Session = Depends(get_db),
_: str = Depends(get_current_username)
):
if get(db, model=models.Project, title=project.title):
raise HTTPException(status_code=409)
return create(db, model=models.Project, data=project)
@app.patch("/projects/{project_id}")
async def patch_project(project_id: int,
request: Request,
db: Session = Depends(get_db),
_: str = Depends(get_current_username),
):
return update(db, models.Project, schemas.ProjectCreate, project_id, **await request.json())
@app.delete("/projects/{project_id}")
def delete_project(project_id: int,
db: Session = Depends(get_db),
_: str = Depends(get_current_username),
):
try:
delete(db, models.Project, project_id)
except DoesntExist:
raise HTTPException(status_code=404)
@app.delete("/posts/{post_id}")
def delete_project(post_id: int,
db: Session = Depends(get_db),
_: str = Depends(get_current_username),
):
try:
delete(db, models.Post, post_id)
except DoesntExist:
raise HTTPException(status_code=404)

75
app/models.py Normal file
View File

@@ -0,0 +1,75 @@
"""
BaseMixIn include `id` as the primary key,
as well as adding a self.when value in the constructor
"""
import datetime as dt
from sqlalchemy import Column, DateTime, String, Boolean, Date
from app.database import Base, SessionLocal
from helpers import parse_date_string, BaseMixIn
def update(instance, **kwargs):
for key, value in kwargs.items():
setattr(instance, key, value)
instance.when = dt.datetime.now()
db = SessionLocal()
db.commit()
class Resume(BaseMixIn, Base):
__tablename__ = "résumé"
when = Column(DateTime, nullable=False)
url = Column(String, nullable=False, unique=True)
class Availability(BaseMixIn, Base):
__tablename__ = "availability"
when = Column(DateTime, nullable=False)
available = Column(Boolean, nullable=False)
next_available = Column(Date)
def __init__(self, next_available=None, **kwargs):
super().__init__(**kwargs)
if next_available:
self.next_available = parse_date_string(next_available)
else:
self.next_available = next_available
class Project(BaseMixIn, Base):
# TODO: add 'tags' table and make a many-to-many relationship between projects and tags
__tablename__ = 'projects'
when = Column(DateTime, nullable=False)
title = Column(String, nullable=False, unique=True)
description = Column(String, nullable=False)
link = Column(String, unique=True)
def update(self, **kwargs):
update(self, **kwargs)
def to_dict(self):
return {attr_name: getattr(self, attr_name) for attr_name in ['title', 'description', 'link']}
class Post(BaseMixIn, Base):
__tablename__ = "posts"
when = Column(DateTime, nullable=False)
title = Column(String, nullable=False, unique=True)
description = Column(String, nullable=False)
body = Column(String, nullable=False)
custom_url = Column(String)
syndicate = Column(Boolean)
def update(self, **kwargs):
update(self, **kwargs)
def to_dict(self):
return {attr_name: getattr(self, attr_name)
for attr_name in ['title', 'description', 'body', 'custom_url', 'syndicate']}

75
app/schemas.py Normal file
View File

@@ -0,0 +1,75 @@
from datetime import date, datetime
from pydantic import BaseModel, UrlStr
class AvailabilityBase(BaseModel):
available: bool
next_available: str = None
class AvailabilityCreate(AvailabilityBase):
pass
class Availability(AvailabilityBase):
id: int
when: datetime
next_available: date = None
class Config:
orm_mode = True
class ResumeBase(BaseModel):
url: UrlStr
class ResumeCreate(ResumeBase):
pass
class Resume(ResumeBase):
id: int
when: datetime
class Config:
orm_mode = True
class ProjectBase(BaseModel):
title: str
description: str
link: UrlStr = None
class ProjectCreate(ProjectBase):
pass
class Project(ProjectBase):
id: int
when: datetime
class Config:
orm_mode = True
class PostBase(BaseModel):
title: str
description: str
body: str
custom_url: str = None
syndicate: bool = None
class PostCreate(PostBase):
pass
class Post(PostBase):
id: int
when: datetime
class Config:
orm_mode = True

7
config.py Normal file
View File

@@ -0,0 +1,7 @@
import os
PERSONAL_API_USERNAME = os.getenv("PERSONAL_API_USERNAME")
PERSONAL_API_PASS = os.getenv("PERSONAL_API_USERNAME")
# must be a postgres instance
SQLALCHEMY_DATABASE_URL = os.getenv("PERSONAL_API_SQLALCHEMY_DATABASE_URL")

34
helpers.py Normal file
View File

@@ -0,0 +1,34 @@
import datetime as dt
import dateparser as dp
from sqlalchemy import Column, Integer, desc
from app.database import SessionLocal
class Invalid(Exception):
pass
def parse_date_string(date_string) -> dt.date:
try:
return dp.parse(date_string).date()
except Exception:
raise Invalid
class BaseMixIn:
id = Column(Integer, primary_key=True, index=True)
__mapper_args__ = {"order_by": desc("id")}
def __init__(self, **kwargs):
super(BaseMixIn, self).__init__(**kwargs)
self.when = dt.datetime.now()
@classmethod
def delete_most_recent(cls):
db = SessionLocal()
db.delete(db.query(cls).first())
db.commit()

201
tests.py Normal file
View File

@@ -0,0 +1,201 @@
"""
TODO: set up a test db or mocks instead of relying there being at least one item available in most of the 'get' tests.
"""
from pytest import skip, mark
from starlette.testclient import TestClient
from app.crud import create_thing
from app.models import Resume, Availability, Project, Post
from config import PERSONAL_API_USERNAME, PERSONAL_API_PASS
from app.main import app
client = TestClient(app)
VALID_AUTH = (PERSONAL_API_USERNAME, PERSONAL_API_PASS)
def test_main_no_auth():
response = client.get('/')
assert response.status_code == 401
assert response.json() == {"detail": "Not authenticated"}
def test_main_wrong_auth():
response = client.get('/', auth=('wronguser', 'wrongpass'))
assert response.status_code == 401
assert response.json() == {"detail": "Forbidden"}
def test_main_correct_auth():
response = client.get('/', auth=VALID_AUTH)
assert response.status_code == 200
assert response.json() == {"Hello": "World"}
def test_get_availability():
response = client.get('/availability/', auth=VALID_AUTH)
assert response.status_code == 200
def test_set_availability():
response = client.post('/availability/',
auth=VALID_AUTH,
json={
"available": False,
"next_available": "in two weeks",
})
assert response.status_code == 200
Availability.delete_most_recent()
assert response.json()['available'] is False
def test_get_resume():
response = client.get('/resume/', auth=VALID_AUTH)
assert response.status_code == 200
def test_post_resume():
response = client.post('/resume/', auth=VALID_AUTH, json={"url": "http://hypo.thetical"})
assert response.status_code == 200
Resume.delete_most_recent()
assert response.json()['url'] == 'http://hypo.thetical'
def test_get_projects():
response = client.get('/projects/', auth=VALID_AUTH)
assert response.status_code == 200
def test_get_project():
response = client.get('/projects/1', auth=VALID_AUTH)
assert response.status_code == 200
def test_post_project():
response = client.post('/projects/', auth=VALID_AUTH,
json={
"title": "Project Time",
"description": "This is a project I built one time.",
"link": "http://hypo.thetical",
})
assert response.status_code == 200
Project.delete_most_recent()
assert response.json()['link'] == 'http://hypo.thetical'
def test_post_project_duplicate_title():
projects = client.get('/projects/', auth=VALID_AUTH).json()
existing_project = projects[0]
original_title = existing_project['title']
response = client.post('/projects/', auth=VALID_AUTH,
json={
"title": original_title,
"description": "yup"
})
assert response.status_code == 409
def test_patch_project():
projects = client.get('/projects/', auth=VALID_AUTH).json()
existing_project = projects[0]
original_title = existing_project['title']
id_ = existing_project['id']
response = client.patch(f'/projects/{id_}', auth=VALID_AUTH, json={"title": "Different Title"})
assert response.status_code == 200
new_title = response.json()['title']
# put it back to how it was
response = client.patch(f'/projects/{id_}', auth=VALID_AUTH, json={"title": original_title})
assert response.status_code == 200
assert new_title == "Different Title"
def test_get_posts():
response = client.get('/posts/', auth=VALID_AUTH)
assert response.status_code == 200
def test_post_post():
response = client.post('/posts/', auth=VALID_AUTH,
json={
"title": "Project Time",
"body": "This is a project I built one time.",
"description": "yup"
})
assert response.status_code == 200
Post.delete_most_recent()
assert response.json()['title'] == 'Project Time'
def test_post_post_insufficient_data():
response = client.post('/posts/', auth=VALID_AUTH,
json={
"title": "Project Time",
"body": "This is a project I built one time.",
})
assert response.status_code == 422
def test_post_post_duplicate_title():
posts = client.get('/posts/', auth=VALID_AUTH).json()
existing_post = posts[0]
original_title = existing_post['title']
response = client.post('/posts/', auth=VALID_AUTH,
json={
"title": original_title,
"body": "This is a project I built one time.",
"description": "yup"
})
assert response.status_code == 409
def test_patch_post():
posts = client.get('/posts/', auth=VALID_AUTH).json()
existing_post = posts[0]
original_title = existing_post['title']
id_ = existing_post['id']
response = client.patch(f'/posts/{id_}', auth=VALID_AUTH, json={"title": "Different Title"})
assert response.status_code == 200
new_title = response.json()['title']
# put it back to how it was
response = client.patch(f'/posts/{id_}', auth=VALID_AUTH, json={"title": original_title})
assert response.status_code == 200
assert new_title == "Different Title"
def test_delete_post():
posts = client.get('/posts/', auth=VALID_AUTH).json()
existing_post = posts[0]
id_ = existing_post['id']
response = client.delete(f"/posts/{id_}", auth=VALID_AUTH)
assert response.status_code == 200
# restore post
response = client.post('/posts/', auth=VALID_AUTH,
json={
"title": existing_post['title'],
"body": existing_post['body'],
"description": existing_post['description'],
})
assert response.status_code == 200
def test_delete_project():
projects = client.get('/projects/', auth=VALID_AUTH).json()
existing_project = projects[0]
id_ = existing_project['id']
print(id_)
response = client.delete(f"/projects/{id_}", auth=VALID_AUTH)
assert response.status_code == 200
# restore project
response = client.post('/projects/', auth=VALID_AUTH,
json={
"title": existing_project['title'],
"link": existing_project['link'],
"description": existing_project['description'],
})
assert response.status_code == 200