first
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
Pipfile
|
||||
codealike.json
|
||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
72
app/crud.py
Normal file
72
app/crud.py
Normal 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
9
app/database.py
Normal 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
153
app/main.py
Normal 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
75
app/models.py
Normal 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
75
app/schemas.py
Normal 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
7
config.py
Normal 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
34
helpers.py
Normal 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
201
tests.py
Normal 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
|
||||
Reference in New Issue
Block a user