├── test ├── __init__.py ├── carsharing.db ├── test_home.py ├── test_get_cars.py └── test_add_car.py ├── routers ├── __init__.py ├── web.py ├── auth.py └── cars.py ├── .gitignore ├── pytest.ini ├── carsharing.db ├── db.py ├── templates ├── search_results.html └── home.html ├── requirements.txt ├── create_user.py ├── cors_demo └── cors_demo.html ├── README.md ├── carsharing.py ├── schemas.py └── cars.json /test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /routers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | **/__pycache__/ 3 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | filterwarnings = 3 | ignore::sqlalchemy.exc.SAWarning -------------------------------------------------------------------------------- /carsharing.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codesensei-courses/fastapi_fundamentals/HEAD/carsharing.db -------------------------------------------------------------------------------- /test/carsharing.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codesensei-courses/fastapi_fundamentals/HEAD/test/carsharing.db -------------------------------------------------------------------------------- /test/test_home.py: -------------------------------------------------------------------------------- 1 | from fastapi.testclient import TestClient 2 | 3 | from carsharing import app 4 | 5 | client = TestClient(app) 6 | 7 | 8 | def test_home(): 9 | response = client.get("/") 10 | assert response.status_code == 200 11 | assert "Welcome" in response.text -------------------------------------------------------------------------------- /db.py: -------------------------------------------------------------------------------- 1 | from sqlmodel import create_engine, Session 2 | 3 | engine = create_engine( 4 | "sqlite:///carsharing.db", 5 | connect_args={"check_same_thread": False}, # Needed for SQLite 6 | echo=True # Log generated SQL 7 | ) 8 | 9 | 10 | def get_session(): 11 | with Session(engine) as session: 12 | yield session -------------------------------------------------------------------------------- /templates/search_results.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Carsharing: Search Results 6 | 7 | 8 |

We found the following matching cars:

9 | 16 | 17 | -------------------------------------------------------------------------------- /test/test_get_cars.py: -------------------------------------------------------------------------------- 1 | from fastapi.testclient import TestClient 2 | 3 | from carsharing import app 4 | 5 | client = TestClient(app) 6 | 7 | 8 | def test_get_cars(): 9 | response = client.get("/api/cars/") 10 | assert response.status_code == 200 11 | cars = response.json() 12 | assert all(["doors" in c for c in cars]) 13 | assert all(["size" in c for c in cars]) 14 | 15 | -------------------------------------------------------------------------------- /templates/home.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Carsharing Demo 4 | 5 | 6 |

Welcome to the Car Sharing service

7 |

Search for the car you need: 8 |

9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 22 | 23 |
21 |
24 | 25 |
26 |

27 | 28 | 29 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | anyio==3.5.0 2 | asgiref==3.5.0 3 | attrs==21.4.0 4 | bcrypt==3.2.0 5 | certifi==2021.10.8 6 | cffi==1.15.0 7 | charset-normalizer==2.0.12 8 | click==8.0.3 9 | dnspython==2.2.0 10 | email-validator==1.1.3 11 | fastapi==0.73.0 12 | h11==0.13.0 13 | httptools==0.2.0 14 | idna==3.3 15 | iniconfig==1.1.1 16 | itsdangerous==2.0.1 17 | Jinja2==3.0.3 18 | MarkupSafe==2.0.1 19 | orjson==3.6.7 20 | packaging==21.3 21 | passlib==1.7.4 22 | pluggy==1.0.0 23 | py==1.11.0 24 | pycparser==2.21 25 | pydantic==1.9.0 26 | pyparsing==3.0.8 27 | python-dotenv==0.19.2 28 | python-multipart==0.0.5 29 | PyYAML==5.4.1 30 | requests==2.27.1 31 | six==1.16.0 32 | sniffio==1.2.0 33 | SQLAlchemy==1.4.32 34 | sqlalchemy2-stubs==0.0.2a21 35 | sqlmodel==0.0.6 36 | starlette==0.17.1 37 | tomli==2.0.1 38 | typing_extensions==4.1.1 39 | ujson==4.3.0 40 | urllib3==1.26.8 41 | uvicorn==0.15.0 42 | uvloop==0.16.0 43 | watchgod==0.7 44 | websockets==10.1 45 | -------------------------------------------------------------------------------- /create_user.py: -------------------------------------------------------------------------------- 1 | """ 2 | create_user.py 3 | ------------- 4 | A convenience script to create a user. 5 | """ 6 | 7 | from getpass import getpass 8 | 9 | from sqlmodel import SQLModel, Session, create_engine 10 | 11 | from schemas import User 12 | 13 | 14 | engine = create_engine( 15 | "sqlite:///carsharing.db", 16 | connect_args={"check_same_thread": False}, # Needed for SQLite 17 | echo=True # Log generated SQL 18 | ) 19 | 20 | 21 | if __name__ == "__main__": 22 | print("Creating tables (if necessary)") 23 | SQLModel.metadata.create_all(engine) 24 | 25 | print("--------") 26 | 27 | print("This script will create a user and save it in the database.") 28 | 29 | username = input("Please enter username\n") 30 | pwd = getpass("Please enter password\n") 31 | 32 | with Session(engine) as session: 33 | user = User(username=username) 34 | user.set_password(pwd) 35 | session.add(user) 36 | session.commit() 37 | -------------------------------------------------------------------------------- /cors_demo/cors_demo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | FastAPI CORS demo 6 | 7 | 8 |

9 | Use the following command to serve this file: 10 |

11 |
 python -m http.server 8080 
12 |

13 | This will server the current file at http://localhost:8080. 14 |

15 |

16 | The button below will send a request to the API at http://localhost:8000/api/cars. 17 |

18 | 19 |

20 |

21 |

22 | 23 | 34 | 35 | -------------------------------------------------------------------------------- /routers/web.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Request, Form, Depends, Cookie 2 | from sqlmodel import Session 3 | from starlette.responses import HTMLResponse 4 | from fastapi.templating import Jinja2Templates 5 | 6 | from db import get_session 7 | from routers.cars import get_cars 8 | 9 | router = APIRouter() 10 | 11 | templates = Jinja2Templates(directory="templates") 12 | 13 | 14 | @router.get("/", response_class=HTMLResponse) 15 | def home(request: Request, cars_cookie: str|None = Cookie(None)): 16 | print(cars_cookie) 17 | return templates.TemplateResponse("home.html", 18 | {"request": request}) 19 | 20 | 21 | @router.post("/search", response_class=HTMLResponse) 22 | def search(*, size: str = Form(...), doors: int = Form(...), 23 | request: Request, 24 | session: Session = Depends(get_session)): 25 | cars = get_cars(size=size, doors=doors, session=session) 26 | return templates.TemplateResponse("search_results.html", 27 | {"request": request, "cars": cars}) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FastAPI Fundamentals 2 | Demo code for the course "FastAPI Fundamentals" on [Pluralsight](https://www.pluralsight.com). 3 | 4 | There's a commit for each module in the course, as well as a tag: 5 | 6 | 7 | - [After module 3: First Steps](https://github.com/codesensei-courses/fastapi_fundamentals/releases/tag/first-steps) 8 | - [After module 4: Serving Data With FastAPI](https://github.com/codesensei-courses/fastapi_fundamentals/releases/tag/serving-data) 9 | - [After module 5: Serving Structured Data Using Pydantic Models](https://github.com/codesensei-courses/fastapi_fundamentals/releases/tag/structured-data-with-pydantic) 10 | - [After module 6: Using a Database](https://github.com/codesensei-courses/fastapi_fundamentals/releases/tag/using-a-database) 11 | - [After module 7: HTTP and FastAPI](https://github.com/codesensei-courses/fastapi_fundamentals/releases/tag/http) 12 | - [After module 8: Authentication](https://github.com/codesensei-courses/fastapi_fundamentals/releases/tag/authentication) 13 | - [After module 9: Testing and Deployment](https://github.com/codesensei-courses/fastapi_fundamentals/releases/tag/test-and-deploy) 14 | -------------------------------------------------------------------------------- /test/test_add_car.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | from fastapi.testclient import TestClient 3 | 4 | from carsharing import app 5 | from routers.cars import add_car 6 | from schemas import CarInput, User, Car 7 | 8 | client = TestClient(app) 9 | 10 | 11 | def test_add_car(): 12 | response = client.post("/api/cars/", 13 | json={ 14 | "doors": 7, 15 | "size": "xxl" 16 | }, headers={'Authorization': 'Bearer reindert'} 17 | ) 18 | assert response.status_code == 200 19 | car = response.json() 20 | assert car['doors'] == 7 21 | assert car['size'] == 'xxl' 22 | 23 | 24 | def test_add_car_with_mock_session(): 25 | mock_session = Mock() 26 | input = CarInput(doors=2, size="xl") 27 | user = User(username="reindert") 28 | result = add_car(car_input=input, session=mock_session, user=user) 29 | 30 | mock_session.add.assert_called_once() 31 | mock_session.commit.assert_called_once() 32 | mock_session.refresh.assert_called_once() 33 | assert isinstance(result, Car) 34 | assert result.doors == 2 35 | assert result.size == "xl" 36 | -------------------------------------------------------------------------------- /routers/auth.py: -------------------------------------------------------------------------------- 1 | from fastapi import Depends, HTTPException, APIRouter 2 | from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm 3 | from sqlmodel import Session, select 4 | from starlette import status 5 | 6 | from db import get_session 7 | from schemas import UserOutput, User 8 | 9 | URL_PREFIX="/auth" 10 | router = APIRouter(prefix=URL_PREFIX) 11 | oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{URL_PREFIX}/token") 12 | 13 | 14 | def get_current_user(token: str = Depends(oauth2_scheme), 15 | session: Session = Depends(get_session)) -> UserOutput: 16 | query = select(User).where(User.username == token) 17 | user = session.exec(query).first() 18 | if user: 19 | return UserOutput.from_orm(user) 20 | else: 21 | raise HTTPException( 22 | status_code=status.HTTP_401_UNAUTHORIZED, 23 | detail="Username or password incorrect", 24 | headers={"WWW-Authenticate": "Bearer"}, 25 | ) 26 | 27 | 28 | @router.post("/token") 29 | async def login(form_data: OAuth2PasswordRequestForm = Depends(), 30 | session: Session = Depends(get_session)): 31 | query = select(User).where(User.username == form_data.username) 32 | user = session.exec(query).first() 33 | if user and user.verify_password(form_data.password): 34 | return {"access_token": user.username, "token_type": "bearer"} 35 | else: 36 | raise HTTPException(status_code=400, detail="Incorrect username or password") -------------------------------------------------------------------------------- /carsharing.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | from fastapi import FastAPI 3 | from fastapi import Request 4 | from fastapi.middleware.cors import CORSMiddleware 5 | from sqlmodel import SQLModel 6 | from starlette.responses import JSONResponse 7 | from starlette.status import HTTP_422_UNPROCESSABLE_ENTITY 8 | 9 | from db import engine 10 | from routers import cars, web, auth 11 | from routers.cars import BadTripException 12 | 13 | app = FastAPI(title="Car Sharing") 14 | app.include_router(web.router) 15 | app.include_router(cars.router) 16 | app.include_router(auth.router) 17 | 18 | origins = [ 19 | "http://localhost:8000", 20 | "http://localhost:8080", 21 | ] 22 | 23 | app.add_middleware( 24 | CORSMiddleware, 25 | allow_origins=origins, 26 | allow_credentials=True, 27 | allow_methods=["*"], 28 | allow_headers=["*"], 29 | ) 30 | 31 | 32 | @app.on_event("startup") 33 | def on_startup(): 34 | SQLModel.metadata.create_all(engine) 35 | 36 | 37 | @app.exception_handler(BadTripException) 38 | async def unicorn_exception_handler(request: Request, exc: BadTripException): 39 | return JSONResponse( 40 | status_code=HTTP_422_UNPROCESSABLE_ENTITY, 41 | content={"message": "Bad Trip"}, 42 | ) 43 | 44 | 45 | # @app.middleware("http") 46 | # async def add_cars_cookie(request: Request, call_next): 47 | # response = await call_next(request) 48 | # response.set_cookie(key="cars_cookie", value="you_visited_the_carsharing_app") 49 | # return response 50 | 51 | 52 | if __name__ == "__main__": 53 | uvicorn.run("carsharing:app", reload=True) 54 | -------------------------------------------------------------------------------- /schemas.py: -------------------------------------------------------------------------------- 1 | from sqlmodel import SQLModel, Field, Relationship, Column, VARCHAR 2 | from passlib.context import CryptContext 3 | 4 | pwd_context = CryptContext(schemes=["bcrypt"]) 5 | 6 | class UserOutput(SQLModel): 7 | id: int 8 | username: str 9 | 10 | 11 | class User(SQLModel, table=True): 12 | id: int | None = Field(default=None, primary_key=True) 13 | username: str = Field(sa_column=Column("username", VARCHAR, unique=True, index=True)) 14 | password_hash: str = "" 15 | 16 | def set_password(self, password): 17 | """Setting the passwords actually sets password_hash.""" 18 | self.password_hash = pwd_context.hash(password) 19 | 20 | def verify_password(self, password): 21 | """Verify given password by hashing and comparing to password_hash.""" 22 | return pwd_context.verify(password, self.password_hash) 23 | 24 | 25 | class TripInput(SQLModel): 26 | start: int 27 | end: int 28 | description: str 29 | 30 | 31 | class TripOutput(TripInput): 32 | id: int 33 | 34 | 35 | class Trip(TripInput, table=True): 36 | id: int | None = Field(default=None, primary_key=True) 37 | car_id: int = Field(foreign_key="car.id") 38 | car: "Car" = Relationship(back_populates="trips") 39 | 40 | 41 | class CarInput(SQLModel): 42 | size: str 43 | fuel: str | None = "electric" 44 | doors: int 45 | transmission: str | None = "auto" 46 | 47 | class Config: 48 | schema_extra = { 49 | "example": { 50 | "size": "m", 51 | "doors": 5, 52 | "transmission": "manual", 53 | "fuel": "hybrid" 54 | } 55 | } 56 | 57 | 58 | class Car(CarInput, table=True): 59 | id: int | None = Field(primary_key=True, default=None) 60 | trips: list[Trip] = Relationship(back_populates="car") 61 | 62 | 63 | class CarOutput(CarInput): 64 | id: int 65 | trips: list[TripOutput] = [] 66 | 67 | -------------------------------------------------------------------------------- /routers/cars.py: -------------------------------------------------------------------------------- 1 | from fastapi import Depends, HTTPException, APIRouter 2 | from sqlmodel import Session, select 3 | 4 | from routers.auth import get_current_user 5 | from db import get_session 6 | from schemas import Car, CarOutput, CarInput, Trip, TripInput, User 7 | 8 | router = APIRouter(prefix="/api/cars") 9 | 10 | 11 | @router.get("/") 12 | def get_cars(size: str | None = None, doors: int | None = None, 13 | session: Session = Depends(get_session)) -> list: 14 | query = select(Car) 15 | if size: 16 | query = query.where(Car.size == size) 17 | if doors: 18 | query = query.where(Car.doors >= doors) 19 | return session.exec(query).all() 20 | 21 | 22 | @router.get("/{id}", response_model=CarOutput) 23 | def car_by_id(id: int, session: Session = Depends(get_session)) -> Car: 24 | car = session.get(Car, id) 25 | if car: 26 | return car 27 | else: 28 | raise HTTPException(status_code=404, detail=f"No car with id={id}.") 29 | 30 | 31 | @router.post("/", response_model=Car) 32 | def add_car(car_input: CarInput, 33 | session: Session = Depends(get_session), 34 | user: User = Depends(get_current_user)) -> Car: 35 | new_car = Car.from_orm(car_input) 36 | session.add(new_car) 37 | session.commit() 38 | session.refresh(new_car) 39 | return new_car 40 | 41 | 42 | @router.delete("/{id}", status_code=204) 43 | def remove_car(id: int, session: Session = Depends(get_session)) -> None: 44 | car = session.get(Car, id) 45 | if car: 46 | session.delete(car) 47 | session.commit() 48 | else: 49 | raise HTTPException(status_code=404, detail=f"No car with id={id}.") 50 | 51 | 52 | @router.put("/{id}", response_model=Car) 53 | def change_car(id: int, new_data: CarInput, 54 | session: Session = Depends(get_session)) -> Car: 55 | car = session.get(Car, id) 56 | if car: 57 | car.fuel = new_data.fuel 58 | car.transmission = new_data.transmission 59 | car.size = new_data.size 60 | car.doors = new_data.doors 61 | session.commit() 62 | return car 63 | else: 64 | raise HTTPException(status_code=404, detail=f"No car with id={id}.") 65 | 66 | 67 | class BadTripException(Exception): 68 | pass 69 | 70 | 71 | @router.post("/{car_id}/trips", response_model=Trip) 72 | def add_trip(car_id: int, trip_input: TripInput, 73 | session: Session = Depends(get_session)) -> Trip: 74 | car = session.get(Car, car_id) 75 | if car: 76 | new_trip = Trip.from_orm(trip_input, update={'car_id': car_id}) 77 | if new_trip.end < new_trip.start: 78 | raise BadTripException("Trip end before start") 79 | car.trips.append(new_trip) 80 | session.commit() 81 | session.refresh(new_trip) 82 | return new_trip 83 | else: 84 | raise HTTPException(status_code=404, detail=f"No car with id={id}.") -------------------------------------------------------------------------------- /cars.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "size": "s", 4 | "fuel": "gasoline", 5 | "doors": 3, 6 | "transmission": "auto", 7 | "trips": [ 8 | { 9 | "start": 0, 10 | "end": 5, 11 | "description": "Groceries", 12 | "id": 1 13 | }, 14 | { 15 | "start": 5, 16 | "end": 218, 17 | "description": "Commute Amsterdam-Rotterdam", 18 | "id": 2 19 | }, 20 | { 21 | "start": 218, 22 | "end": 257, 23 | "description": "Weekend beach trip", 24 | "id": 3 25 | } 26 | ], 27 | "id": 1 28 | }, 29 | { 30 | "size": "s", 31 | "fuel": "electric", 32 | "doors": 3, 33 | "transmission": "auto", 34 | "trips": [ 35 | { 36 | "start": 0, 37 | "end": 34, 38 | "description": "Taking dog to the vet", 39 | "id": 4 40 | }, 41 | { 42 | "start": 34, 43 | "end": 125, 44 | "description": "Meeting Customer in Utrecht", 45 | "id": 5 46 | } 47 | ], 48 | "id": 2 49 | }, 50 | { 51 | "size": "s", 52 | "fuel": "gasoline", 53 | "doors": 5, 54 | "transmission": "manual", 55 | "trips": [], 56 | "id": 3 57 | }, 58 | { 59 | "size": "m", 60 | "fuel": "electric", 61 | "doors": 3, 62 | "transmission": "auto", 63 | "trips": [ 64 | { 65 | "start": 0, 66 | "end": 100, 67 | "description": "Visiting mom", 68 | "id": 6 69 | } 70 | ], 71 | "id": 4 72 | }, 73 | { 74 | "size": "m", 75 | "fuel": "gasoline", 76 | "doors": 5, 77 | "transmission": "manual", 78 | "trips": [], 79 | "id": 6 80 | }, 81 | { 82 | "size": "l", 83 | "fuel": "diesel", 84 | "doors": 5, 85 | "transmission": "manual", 86 | "trips": [], 87 | "id": 7 88 | }, 89 | { 90 | "size": "l", 91 | "fuel": "electric", 92 | "doors": 5, 93 | "transmission": "auto", 94 | "trips": [], 95 | "id": 8 96 | }, 97 | { 98 | "size": "l", 99 | "fuel": "hybrid", 100 | "doors": 5, 101 | "transmission": "auto", 102 | "trips": [ 103 | { 104 | "start": 0, 105 | "end": 55, 106 | "description": "Forest walk", 107 | "id": 7 108 | } 109 | ], 110 | "id": 9 111 | }, 112 | { 113 | "size": "xl", 114 | "fuel": "electric", 115 | "doors": 5, 116 | "transmission": "auto", 117 | "trips": [], 118 | "id": 10 119 | } 120 | ] --------------------------------------------------------------------------------