From 0a0ceaf7d700b9f8765202bc6e641f5f2058ed24 Mon Sep 17 00:00:00 2001 From: adii1823 <64855277+adii1823@users.noreply.github.com> Date: Thu, 28 Oct 2021 17:41:38 +0530 Subject: [PATCH] ch14 --- ch14/README.md | 56 ++++ ch14/api_code/.env | 4 + ch14/api_code/api/__init__.py | 1 + ch14/api_code/api/admin.py | 42 +++ ch14/api_code/api/config.py | 11 + ch14/api_code/api/crud.py | 231 ++++++++++++++++ ch14/api_code/api/database.py | 24 ++ ch14/api_code/api/deps.py | 20 ++ ch14/api_code/api/models.py | 181 ++++++++++++ ch14/api_code/api/schemas.py | 118 ++++++++ ch14/api_code/api/stations.py | 122 +++++++++ ch14/api_code/api/tickets.py | 53 ++++ ch14/api_code/api/trains.py | 84 ++++++ ch14/api_code/api/users.py | 146 ++++++++++ ch14/api_code/api/util.py | 54 ++++ ch14/api_code/dummy_data.py | 182 +++++++++++++ ch14/api_code/main.py | 20 ++ ch14/api_code/queries.md | 125 +++++++++ ch14/api_code/train.db | Bin 0 -> 122880 bytes ch14/apic/apic/__init__.py | 1 + ch14/apic/apic/asgi.py | 17 ++ ch14/apic/apic/settings.py | 132 +++++++++ ch14/apic/apic/urls.py | 23 ++ ch14/apic/apic/wsgi.py | 17 ++ ch14/apic/manage.py | 27 ++ ch14/apic/rails/__init__.py | 1 + ch14/apic/rails/admin.py | 4 + ch14/apic/rails/apps.py | 7 + ch14/apic/rails/forms.py | 9 + ch14/apic/rails/migrations/__init__.py | 1 + ch14/apic/rails/models.py | 4 + ch14/apic/rails/static/rails/style.css | 29 ++ ch14/apic/rails/templates/rails/arrivals.html | 54 ++++ .../rails/templates/rails/authenticate.html | 21 ++ .../templates/rails/authenticate.result.html | 38 +++ ch14/apic/rails/templates/rails/base.html | 17 ++ .../rails/templates/rails/departures.html | 53 ++++ ch14/apic/rails/templates/rails/index.html | 26 ++ ch14/apic/rails/templates/rails/stations.html | 52 ++++ ch14/apic/rails/templates/rails/users.html | 50 ++++ ch14/apic/rails/tests.py | 4 + ch14/apic/rails/urls.py | 32 +++ ch14/apic/rails/views.py | 169 ++++++++++++ ch14/pyproject.toml | 6 + ch14/requirements/dev.in | 5 + ch14/requirements/dev.txt | 256 +++++++++++++++++ ch14/requirements/requirements.in | 8 + ch14/requirements/requirements.txt | 62 +++++ ch14/samples/api.calls/stations.txt | 257 ++++++++++++++++++ ch14/samples/api.calls/users.txt | 12 + ch14/samples/typing.examples.py | 77 ++++++ 51 files changed, 2945 insertions(+) create mode 100644 ch14/README.md create mode 100644 ch14/api_code/.env create mode 100644 ch14/api_code/api/__init__.py create mode 100644 ch14/api_code/api/admin.py create mode 100644 ch14/api_code/api/config.py create mode 100644 ch14/api_code/api/crud.py create mode 100644 ch14/api_code/api/database.py create mode 100644 ch14/api_code/api/deps.py create mode 100644 ch14/api_code/api/models.py create mode 100644 ch14/api_code/api/schemas.py create mode 100644 ch14/api_code/api/stations.py create mode 100644 ch14/api_code/api/tickets.py create mode 100644 ch14/api_code/api/trains.py create mode 100644 ch14/api_code/api/users.py create mode 100644 ch14/api_code/api/util.py create mode 100644 ch14/api_code/dummy_data.py create mode 100644 ch14/api_code/main.py create mode 100644 ch14/api_code/queries.md create mode 100644 ch14/api_code/train.db create mode 100644 ch14/apic/apic/__init__.py create mode 100644 ch14/apic/apic/asgi.py create mode 100644 ch14/apic/apic/settings.py create mode 100644 ch14/apic/apic/urls.py create mode 100644 ch14/apic/apic/wsgi.py create mode 100644 ch14/apic/manage.py create mode 100644 ch14/apic/rails/__init__.py create mode 100644 ch14/apic/rails/admin.py create mode 100644 ch14/apic/rails/apps.py create mode 100644 ch14/apic/rails/forms.py create mode 100644 ch14/apic/rails/migrations/__init__.py create mode 100644 ch14/apic/rails/models.py create mode 100644 ch14/apic/rails/static/rails/style.css create mode 100644 ch14/apic/rails/templates/rails/arrivals.html create mode 100644 ch14/apic/rails/templates/rails/authenticate.html create mode 100644 ch14/apic/rails/templates/rails/authenticate.result.html create mode 100644 ch14/apic/rails/templates/rails/base.html create mode 100644 ch14/apic/rails/templates/rails/departures.html create mode 100644 ch14/apic/rails/templates/rails/index.html create mode 100644 ch14/apic/rails/templates/rails/stations.html create mode 100644 ch14/apic/rails/templates/rails/users.html create mode 100644 ch14/apic/rails/tests.py create mode 100644 ch14/apic/rails/urls.py create mode 100644 ch14/apic/rails/views.py create mode 100644 ch14/pyproject.toml create mode 100644 ch14/requirements/dev.in create mode 100644 ch14/requirements/dev.txt create mode 100644 ch14/requirements/requirements.in create mode 100644 ch14/requirements/requirements.txt create mode 100644 ch14/samples/api.calls/stations.txt create mode 100644 ch14/samples/api.calls/users.txt create mode 100644 ch14/samples/typing.examples.py diff --git a/ch14/README.md b/ch14/README.md new file mode 100644 index 0000000..f4b5278 --- /dev/null +++ b/ch14/README.md @@ -0,0 +1,56 @@ +# CH14 - Readme + +This chapter is about APIs. You will find three main folders: + +1. `api_code` contains the FastAPI project about Railways +2. `apic` or API Consumer. This is a Django project to show an example on how to talk to an API from another application. +3. `samples`. This folder contains the examples that went in the chapter, so you can ignore it. + +## Setup + +Install requirements with pip from their folder: + + $ pip install -r requirements.txt + +If you want to create your own dummy data, please also install the dev requirements: + + $ pip install -r dev.txt + +To generate a new database with random data: + + $ cd api_code + $ python dummy_data.py + +This will generate a new `train.db` file for you. + +## Running the API + +Open a terminal window, change into the `api_code` folder and type this command: + + $ uvicorn main:app --reload + +The `--reload` flag makes sure uvicorn is reloaded if you make changes to the +source while the app is running. + +## Running the Django consumer + +While the API is running in a terminal, open another terminal window, +change into the `apic` folder, and type the following: + + $ python manage.py migrate # only the first time, to generate the django db + +Once migrations have been applied, run this to start the app: + + $ python manage.py runserver 8080 + +We need to specify a port for Django that is not 8000 (its normal default), +since the API is running on that one. + +When the Django app is running, open a browser and go to: http://localhost:8080 + +You should see the welcome page of the Railways app. From there you can navigate using links. + +In the authentication section, you can use these credentials to authenticate: + + username: fabrizio.romano@example.com + password: f4bPassword diff --git a/ch14/api_code/.env b/ch14/api_code/.env new file mode 100644 index 0000000..8011cb9 --- /dev/null +++ b/ch14/api_code/.env @@ -0,0 +1,4 @@ +# api_code/.env +SECRET_KEY="ec604d5610ac4668a44418711be8251f" +DEBUG=false +API_VERSION=1.0.0 diff --git a/ch14/api_code/api/__init__.py b/ch14/api_code/api/__init__.py new file mode 100644 index 0000000..d340baa --- /dev/null +++ b/ch14/api_code/api/__init__.py @@ -0,0 +1 @@ +# api_code/api/__init__.py diff --git a/ch14/api_code/api/admin.py b/ch14/api_code/api/admin.py new file mode 100644 index 0000000..766d34f --- /dev/null +++ b/ch14/api_code/api/admin.py @@ -0,0 +1,42 @@ +# api_code/api/admin.py +from typing import Optional + +from fastapi import ( + APIRouter, + Depends, + Header, + HTTPException, + Response, + status, +) +from sqlalchemy.orm import Session + +from . import crud +from .deps import Settings, get_db, get_settings +from .util import is_admin + +router = APIRouter(prefix="/admin") + + +def ensure_admin(settings: Settings, authorization: str): + if not is_admin( + settings=settings, authorization=authorization + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=f"You must be an admin to access this endpoint.", + ) + + +@router.delete("/stations/{station_id}", tags=["Admin"]) +def admin_delete_station( + station_id: int, + authorization: Optional[str] = Header(None), + settings: Settings = Depends(get_settings), + db: Session = Depends(get_db), +): + ensure_admin(settings, authorization) + row_count = crud.delete_station(db=db, station_id=station_id) + if row_count: + return Response(status_code=status.HTTP_204_NO_CONTENT) + return Response(status_code=status.HTTP_404_NOT_FOUND) diff --git a/ch14/api_code/api/config.py b/ch14/api_code/api/config.py new file mode 100644 index 0000000..1a8fe74 --- /dev/null +++ b/ch14/api_code/api/config.py @@ -0,0 +1,11 @@ +# api_code/api/config.py +from pydantic import BaseSettings + + +class Settings(BaseSettings): + secret_key: str + debug: bool + api_version: str + + class Config: + env_file = ".env" diff --git a/ch14/api_code/api/crud.py b/ch14/api_code/api/crud.py new file mode 100644 index 0000000..8ccdb93 --- /dev/null +++ b/ch14/api_code/api/crud.py @@ -0,0 +1,231 @@ +# api_code/api/crud.py +from datetime import datetime, timezone + +from sqlalchemy import delete, update +from sqlalchemy.orm import Session, aliased + +from . import models, schemas + +# USERS + + +def get_users(db: Session, email: str = None): + q = db.query(models.User) + if email is not None: + q = q.filter(models.User.email.ilike(f"%{email}%")) + return q.all() + + +def get_user(db: Session, user_id: int): + return ( + db.query(models.User) + .filter(models.User.id == user_id) + .first() + ) + + +def get_user_by_email(db: Session, email: str): + return ( + db.query(models.User) + .filter(models.User.email.ilike(email)) + .first() + ) + + +def create_user( + db: Session, user: schemas.UserCreate, user_id: int = None +): + hashed_password = models.User.hash_password(user.password) + user_dict = { + **user.dict(exclude_unset=True), + "password": hashed_password, + } + if user_id is not None: + user_dict.update({"id": user_id}) + db_user = models.User(**user_dict) + db.add(db_user) + db.commit() + db.refresh(db_user) + return db_user + + +def update_user( + db: Session, user: schemas.UserUpdate, user_id: int +): + user_dict = { + **user.dict(exclude_unset=True), + } + if user.password is not None: + user_dict.update( + {"password": models.User.hash_password(user.password)} + ) + stm = ( + update(models.User) + .where(models.User.id == user_id) + .values(user_dict) + ) + result = db.execute(stm) + db.commit() + return result.rowcount + + +def delete_user(db: Session, user_id: int): + stm = delete(models.User).where(models.User.id == user_id) + result = db.execute(stm) + db.commit() + return result.rowcount + + +# STATIONS + + +def get_stations(db: Session, code: str = None): + q = db.query(models.Station) + if code is not None: + q = q.filter(models.Station.code.ilike(code)) + return q.all() + + +def get_station(db: Session, station_id: int): + return ( + db.query(models.Station) + .filter(models.Station.id == station_id) + .first() + ) + + +def get_station_by_code(db: Session, code: str): + return ( + db.query(models.Station) + .filter(models.Station.code.ilike(code)) + .first() + ) + + +def create_station( + db: Session, + station: schemas.StationCreate, +): + db_station = models.Station(**station.dict()) + db.add(db_station) + db.commit() + db.refresh(db_station) + return db_station + + +def update_station( + db: Session, station: schemas.StationUpdate, station_id: int +): + stm = ( + update(models.Station) + .where(models.Station.id == station_id) + .values(station.dict(exclude_unset=True)) + ) + result = db.execute(stm) + db.commit() + return result.rowcount + + +def delete_station(db: Session, station_id: int): + stm = delete(models.Station).where( + models.Station.id == station_id + ) + result = db.execute(stm) + db.commit() + return result.rowcount + + +# TRAINS + + +def get_trains( + db: Session, + station_from_code: str = None, + station_to_code: str = None, + include_all: bool = False, +): + q = db.query(models.Train) + + if station_from_code is not None: + st_from = aliased(models.Station) + q = q.join(st_from, models.Train.station_from) + q = q.filter(st_from.code.ilike(f"%{station_from_code}%")) + + if station_to_code is not None: + st_to = aliased(models.Station) + q = q.join(st_to, models.Train.station_to) + q = q.filter(st_to.code.ilike(f"%{station_to_code}%")) + + if not include_all: + now = datetime.now(tz=timezone.utc) + q = q.filter(models.Train.departs_at > now) + + return q.all() + + +def get_train(db: Session, train_id: int): + return ( + db.query(models.Train) + .filter(models.Train.id == train_id) + .first() + ) + + +def get_train_by_name(db: Session, name: str): + return ( + db.query(models.Train) + .filter(models.Train.name == name) + .first() + ) + + +def create_train(db: Session, train: schemas.TrainCreate): + train_dict = train.dict(exclude_unset=True) + db_train = models.Train(**train_dict) + db.add(db_train) + db.commit() + db.refresh(db_train) + return db_train + + +def delete_train(db: Session, train_id: int): + stm = delete(models.Train).where(models.Train.id == train_id) + result = db.execute(stm) + db.commit() + return result.rowcount + + +# TICKETS + + +def get_tickets(db: Session): + return db.query(models.Ticket).all() + + +def get_ticket(db: Session, ticket_id: int): + return ( + db.query(models.Ticket) + .filter(models.Ticket.id == ticket_id) + .first() + ) + + +def create_ticket(db: Session, ticket: schemas.TicketCreate): + ticket_dict = ticket.dict(exclude_unset=True) + ticket_dict.update( + {"created_at": datetime.now(tz=timezone.utc)} + ) + db_ticket = models.Ticket(**ticket_dict) + db.add(db_ticket) + db.commit() + db.refresh(db_ticket) + return db_ticket + + +def delete_ticket(db: Session, ticket_id: int): + stm = delete(models.Ticket).where( + models.Ticket.id == ticket_id + ) + result = db.execute(stm) + db.commit() + return result.rowcount diff --git a/ch14/api_code/api/database.py b/ch14/api_code/api/database.py new file mode 100644 index 0000000..a806331 --- /dev/null +++ b/ch14/api_code/api/database.py @@ -0,0 +1,24 @@ +# api_code/api/database.py +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +from .config import Settings + +settings = Settings() + + +DB_URL = "sqlite:///train.db" + + +engine = create_engine( + DB_URL, + connect_args={"check_same_thread": False}, + echo=settings.debug, # when debug is True, queries are logged +) + +SessionLocal = sessionmaker( + autocommit=False, autoflush=False, bind=engine +) + +Base = declarative_base() diff --git a/ch14/api_code/api/deps.py b/ch14/api_code/api/deps.py new file mode 100644 index 0000000..27aff14 --- /dev/null +++ b/ch14/api_code/api/deps.py @@ -0,0 +1,20 @@ +# api_code/api/deps.py +from functools import lru_cache + +from .config import Settings +from .database import SessionLocal + + +def get_db(): + """Return a DB Session.""" + db = SessionLocal() + try: + yield db + finally: + db.close() + + +@lru_cache +def get_settings(): + """Return the app settings.""" + return Settings() diff --git a/ch14/api_code/api/models.py b/ch14/api_code/api/models.py new file mode 100644 index 0000000..5584820 --- /dev/null +++ b/ch14/api_code/api/models.py @@ -0,0 +1,181 @@ +# api_code/api/models.py +import enum +import hashlib +import os +import secrets + +from sqlalchemy import ( + Column, + DateTime, + Enum, + Float, + ForeignKey, + Integer, + Unicode, +) +from sqlalchemy.orm import relationship + +from .database import Base + +UNICODE_LEN = 128 +SALT_LEN = 64 + +# Enums + + +class Classes(str, enum.Enum): + first = "first" + second = "second" + + +class Roles(str, enum.Enum): + admin = "admin" + passenger = "passenger" + + +# Models + + +class Station(Base): + __tablename__ = "station" + + id = Column(Integer, primary_key=True) + code = Column( + Unicode(UNICODE_LEN), nullable=False, unique=True + ) + country = Column(Unicode(UNICODE_LEN), nullable=False) + city = Column(Unicode(UNICODE_LEN), nullable=False) + + departures = relationship( + "Train", + foreign_keys="[Train.station_from_id]", + back_populates="station_from", + ) + arrivals = relationship( + "Train", + foreign_keys="[Train.station_to_id]", + back_populates="station_to", + ) + + def __repr__(self): + return f"<{self.code}: id={self.id} city={self.city}>" + + __str__ = __repr__ + + +class Train(Base): + __tablename__ = "train" + + id = Column(Integer, primary_key=True) + name = Column(Unicode(UNICODE_LEN), nullable=False) + + station_from_id = Column( + ForeignKey("station.id"), nullable=False + ) + station_from = relationship( + "Station", + foreign_keys=[station_from_id], + back_populates="departures", + ) + + station_to_id = Column( + ForeignKey("station.id"), nullable=False + ) + station_to = relationship( + "Station", + foreign_keys=[station_to_id], + back_populates="arrivals", + ) + + departs_at = Column(DateTime(timezone=True), nullable=False) + arrives_at = Column(DateTime(timezone=True), nullable=False) + + first_class = Column(Integer, default=0, nullable=False) + second_class = Column(Integer, default=0, nullable=False) + seats_per_car = Column(Integer, default=0, nullable=False) + + tickets = relationship("Ticket", back_populates="train") + + def __repr__(self): + return f"<{self.name}: id={self.id}>" + + __str__ = __repr__ + + +class Ticket(Base): + __tablename__ = "ticket" + + id = Column(Integer, primary_key=True) + created_at = Column(DateTime(timezone=True), nullable=False) + user_id = Column(ForeignKey("user.id"), nullable=False) + user = relationship( + "User", foreign_keys=[user_id], back_populates="tickets" + ) + + train_id = Column(ForeignKey("train.id"), nullable=False) + train = relationship( + "Train", foreign_keys=[train_id], back_populates="tickets" + ) + + price = Column(Float, default=0, nullable=False) + car_class = Column(Enum(Classes), nullable=False) + + def __repr__(self): + return ( + f"" + ) + + __str__ = __repr__ + + +class User(Base): + __tablename__ = "user" + + pwd_separator = "#" + + id = Column(Integer, primary_key=True) + full_name = Column(Unicode(UNICODE_LEN), nullable=False) + email = Column(Unicode(256), nullable=False, unique=True) + password = Column(Unicode(256), nullable=False) + role = Column(Enum(Roles), nullable=False) + + tickets = relationship("Ticket", back_populates="user") + + def is_valid_password(self, password: str): + """Tell if password matches the one stored in DB.""" + salt, stored_hash = self.password.split( + self.pwd_separator + ) + _, computed_hash = _hash( + password=password, salt=bytes.fromhex(salt) + ) + return secrets.compare_digest(stored_hash, computed_hash) + + @classmethod + def hash_password(cls, password: str, salt: bytes = None): + salt, hashed = _hash(password=password, salt=salt) + return f"{salt}{cls.pwd_separator}{hashed}" + + def __repr__(self): + return ( + f"<{self.full_name}: id={self.id} " + f"role={self.role.name}>" + ) + + __str__ = __repr__ + + +def _hash(password: str, salt: bytes = None): + if salt is None: + salt = os.urandom(SALT_LEN) + iterations = 100 # should be at least 100k for SHA-256 + + hashed = hashlib.pbkdf2_hmac( + "sha256", + password.encode("utf-8"), + salt, + iterations, + dklen=128, + ) + + return salt.hex(), hashed.hex() diff --git a/ch14/api_code/api/schemas.py b/ch14/api_code/api/schemas.py new file mode 100644 index 0000000..9a8b925 --- /dev/null +++ b/ch14/api_code/api/schemas.py @@ -0,0 +1,118 @@ +# api_code/api/schemas.py +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel + +from . import models + +# USERS + + +class Auth(BaseModel): + email: str + password: str + + +class AuthToken(BaseModel): + token: str + + +class UserBase(BaseModel): + full_name: str + email: str + role: models.Roles + + +class User(UserBase): + id: int + + class Config: + orm_mode = True + use_enum_values = True + + +class UserCreate(UserBase): + password: str + + +class UserUpdate(UserBase): + full_name: Optional[str] = None + email: Optional[str] = None + password: Optional[str] = None + role: Optional[models.Roles] = None + + +# STATIONS + + +class StationBase(BaseModel): + code: str + country: str + city: str + + +class Station(StationBase): + id: int + + class Config: + orm_mode = True + + +class StationCreate(StationBase): + pass + + +class StationUpdate(StationBase): + code: Optional[str] = None + country: Optional[str] = None + city: Optional[str] = None + + +# TRAINS + + +class TrainBase(BaseModel): + name: str + station_from: Optional[Station] = None + station_to: Optional[Station] = None + departs_at: datetime + arrives_at: datetime + first_class: int + second_class: int + seats_per_car: int + + +class Train(TrainBase): + id: int + + class Config: + orm_mode = True + + +class TrainCreate(TrainBase): + station_from_id: int + station_to_id: int + + +# TICKETS + + +class TicketBase(BaseModel): + user_id: int + train_id: int + price: float + car_class: models.Classes + + +class Ticket(TicketBase): + id: int + created_at: datetime + + class Config: + orm_mode = True + use_enum_values = True + + +class TicketCreate(TicketBase): + pass diff --git a/ch14/api_code/api/stations.py b/ch14/api_code/api/stations.py new file mode 100644 index 0000000..8d83854 --- /dev/null +++ b/ch14/api_code/api/stations.py @@ -0,0 +1,122 @@ +# api_code/api/stations.py +from typing import Optional + +from fastapi import ( + APIRouter, + Depends, + HTTPException, + Response, + status, +) +from sqlalchemy.orm import Session + +from . import crud +from .deps import get_db +from .schemas import Station, StationCreate, StationUpdate, Train + +router = APIRouter(prefix="/stations") + + +@router.get("", response_model=list[Station], tags=["Stations"]) +def get_stations( + db: Session = Depends(get_db), code: Optional[str] = None +): + return crud.get_stations(db=db, code=code) + + +@router.get( + "/{station_id}", response_model=Station, tags=["Stations"] +) +def get_station(station_id: int, db: Session = Depends(get_db)): + db_station = crud.get_station(db=db, station_id=station_id) + if db_station is None: + raise HTTPException( + status_code=404, + detail=f"Station {station_id} not found.", + ) + return db_station + + +@router.get( + "/{station_id}/departures", + response_model=list[Train], + tags=["Trains"], +) +def get_station_departures( + station_id: int, db: Session = Depends(get_db) +): + db_station = _get_station(db=db, station_id=station_id) + return db_station.departures + + +def _get_station(db: Session, station_id: int): + db_station = crud.get_station(db=db, station_id=station_id) + if db_station is None: + raise HTTPException( + status_code=404, + detail=f"Station {station_id} not found.", + ) + return db_station + + +@router.get( + "/{station_id}/arrivals", + response_model=list[Train], + tags=["Trains"], +) +def get_station_arrivals( + station_id: int, db: Session = Depends(get_db) +): + db_station = _get_station(db=db, station_id=station_id) + return db_station.arrivals + + +@router.post( + "", + response_model=Station, + status_code=status.HTTP_201_CREATED, + tags=["Stations"], +) +def create_station( + station: StationCreate, db: Session = Depends(get_db) +): + db_station = crud.get_station_by_code( + db=db, code=station.code + ) + if db_station: + raise HTTPException( + status_code=400, + detail=f"Station {station.code} already exists.", + ) + return crud.create_station(db=db, station=station) + + +@router.put("/{station_id}", tags=["Stations"]) +def update_station( + station_id: int, + station: StationUpdate, + db: Session = Depends(get_db), +): + db_station = crud.get_station(db=db, station_id=station_id) + + if db_station is None: + raise HTTPException( + status_code=404, + detail=f"Station {station_id} not found.", + ) + + else: + crud.update_station( + db=db, station=station, station_id=station_id + ) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.delete("/{station_id}", tags=["Stations"]) +def delete_station( + station_id: int, db: Session = Depends(get_db) +): + row_count = crud.delete_station(db=db, station_id=station_id) + if row_count: + return Response(status_code=status.HTTP_204_NO_CONTENT) + return Response(status_code=status.HTTP_404_NOT_FOUND) diff --git a/ch14/api_code/api/tickets.py b/ch14/api_code/api/tickets.py new file mode 100644 index 0000000..39f6ac5 --- /dev/null +++ b/ch14/api_code/api/tickets.py @@ -0,0 +1,53 @@ +# api_code/api/tickets.py +from fastapi import ( + APIRouter, + Depends, + HTTPException, + Response, + status, +) +from sqlalchemy.orm import Session + +from . import crud +from .deps import get_db +from .schemas import Ticket, TicketCreate + +router = APIRouter(prefix="/tickets") + + +@router.get("", response_model=list[Ticket], tags=["Tickets"]) +def get_tickets(db: Session = Depends(get_db)): + return crud.get_tickets(db=db) + + +@router.get( + "/{ticket_id}", response_model=Ticket, tags=["Tickets"] +) +def get_ticket(ticket_id: int, db: Session = Depends(get_db)): + db_ticket = crud.get_ticket(db=db, ticket_id=ticket_id) + if db_ticket is None: + raise HTTPException( + status_code=404, + detail=f"Ticket {ticket_id} not found.", + ) + return db_ticket + + +@router.post( + "", + response_model=Ticket, + status_code=status.HTTP_201_CREATED, + tags=["Tickets"], +) +def create_ticket( + ticket: TicketCreate, db: Session = Depends(get_db) +): + return crud.create_ticket(db=db, ticket=ticket) + + +@router.delete("/{ticket_id}", tags=["Tickets"]) +def delete_ticket(ticket_id: int, db: Session = Depends(get_db)): + row_count = crud.delete_ticket(db=db, ticket_id=ticket_id) + if row_count: + return Response(status_code=status.HTTP_204_NO_CONTENT) + return Response(status_code=status.HTTP_404_NOT_FOUND) diff --git a/ch14/api_code/api/trains.py b/ch14/api_code/api/trains.py new file mode 100644 index 0000000..9f5aa3b --- /dev/null +++ b/ch14/api_code/api/trains.py @@ -0,0 +1,84 @@ +# api_code/api/trains.py +from typing import Optional + +from fastapi import ( + APIRouter, + Depends, + HTTPException, + Response, + status, +) +from sqlalchemy.orm import Session + +from . import crud +from .deps import get_db +from .schemas import Ticket, Train, TrainCreate + +router = APIRouter(prefix="/trains") + + +@router.get("", response_model=list[Train], tags=["Trains"]) +def get_trains( + db: Session = Depends(get_db), + station_from_code: str = None, + station_to_code: str = None, + include_all: Optional[bool] = False, +): + return crud.get_trains( + db=db, + station_from_code=station_from_code, + station_to_code=station_to_code, + include_all=include_all, + ) + + +@router.get("/{train_id}", response_model=Train, tags=["Trains"]) +def get_train(train_id: int, db: Session = Depends(get_db)): + db_train = crud.get_train(db=db, train_id=train_id) + if db_train is None: + raise HTTPException( + status_code=404, detail=f"Train {train_id} not found." + ) + return db_train + + +@router.get( + "/{train_id}/tickets", + response_model=list[Ticket], + tags=["Tickets"], +) +def get_train_tickets( + train_id: int, db: Session = Depends(get_db) +): + db_train = crud.get_train(db=db, train_id=train_id) + if db_train is None: + raise HTTPException( + status_code=404, detail=f"Train {train_id} not found." + ) + return db_train.tickets + + +@router.post( + "", + response_model=Train, + status_code=status.HTTP_201_CREATED, + tags=["Trains"], +) +def create_train( + train: TrainCreate, db: Session = Depends(get_db) +): + db_train = crud.get_train_by_name(db=db, name=train.name) + if db_train: + raise HTTPException( + status_code=400, + detail=f"Train {train.name} already exists.", + ) + return crud.create_train(db=db, train=train) + + +@router.delete("/{train_id}", tags=["Trains"]) +def delete_user(train_id: int, db: Session = Depends(get_db)): + row_count = crud.delete_train(db=db, train_id=train_id) + if row_count: + return Response(status_code=status.HTTP_204_NO_CONTENT) + return Response(status_code=status.HTTP_404_NOT_FOUND) diff --git a/ch14/api_code/api/users.py b/ch14/api_code/api/users.py new file mode 100644 index 0000000..980933a --- /dev/null +++ b/ch14/api_code/api/users.py @@ -0,0 +1,146 @@ +# api_code/api/users.py +from typing import Optional + +from fastapi import ( + APIRouter, + Depends, + HTTPException, + Response, + status, +) +from sqlalchemy.orm import Session + +from . import crud +from .deps import Settings, get_db, get_settings +from .schemas import ( + Auth, + AuthToken, + Ticket, + User, + UserCreate, + UserUpdate, +) +from .util import InvalidToken, create_token, extract_payload + +router = APIRouter(prefix="/users") + + +@router.get("", response_model=list[User], tags=["Users"]) +def get_users( + db: Session = Depends(get_db), email: Optional[str] = None +): + return crud.get_users(db=db, email=email) + + +@router.get("/{user_id}", response_model=User, tags=["Users"]) +def get_user(user_id: int, db: Session = Depends(get_db)): + db_user = crud.get_user(db=db, user_id=user_id) + if db_user is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"User {user_id} not found.", + ) + return db_user + + +@router.get( + "/{user_id}/tickets", + response_model=list[Ticket], + tags=["Users"], +) +def get_user_tickets(user_id: int, db: Session = Depends(get_db)): + db_user = crud.get_user(db=db, user_id=user_id) + if db_user is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"User {user_id} not found.", + ) + return db_user.tickets + + +@router.post( + "", + response_model=User, + status_code=status.HTTP_201_CREATED, + tags=["Users"], +) +def create_user(user: UserCreate, db: Session = Depends(get_db)): + db_user = crud.get_user_by_email(db=db, email=user.email) + if db_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"User {user.email} already exists.", + ) + return crud.create_user(db=db, user=user) + + +@router.put("/{user_id}", response_model=User, tags=["Users"]) +def update_user( + user_id: int, user: UserUpdate, db: Session = Depends(get_db) +): + db_user = crud.get_user(db=db, user_id=user_id) + + if db_user is None: + db_user = crud.get_user_by_email(db, user.email) + + if db_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"User {user.email} already exists.", + ) + + else: + crud.create_user(db=db, user=user, user_id=user_id) + return Response(status_code=status.HTTP_201_CREATED) + + else: + crud.update_user(db=db, user=user, user_id=user_id) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.delete("/{user_id}", tags=["Users"]) +def delete_user(user_id: int, db: Session = Depends(get_db)): + row_count = crud.delete_user(db=db, user_id=user_id) + if row_count: + return Response(status_code=status.HTTP_204_NO_CONTENT) + return Response(status_code=status.HTTP_404_NOT_FOUND) + + +@router.post("/authenticate", tags=["Auth"]) +def authenticate( + auth: Auth, + db: Session = Depends(get_db), + settings: Settings = Depends(get_settings), +): + db_user = crud.get_user_by_email(db=db, email=auth.email) + if db_user is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"User {auth.email} not found.", + ) + + if not db_user.is_valid_password(auth.password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Wrong username/password.", + ) + + payload = { + "email": auth.email, + "role": db_user.role.value, + } + return create_token(payload, settings.secret_key) + + +@router.post("/validate_token", tags=["Auth"]) +def validate_token( + auth: AuthToken, + settings: Settings = Depends(get_settings), +): + try: + return extract_payload(auth.token, settings.secret_key) + except InvalidToken as err: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid token: {err}", + ) diff --git a/ch14/api_code/api/util.py b/ch14/api_code/api/util.py new file mode 100644 index 0000000..6863040 --- /dev/null +++ b/ch14/api_code/api/util.py @@ -0,0 +1,54 @@ +# api_code/api/util.py +from datetime import datetime, timedelta, timezone +from typing import Optional + +import jwt +from jwt.exceptions import PyJWTError + +from .deps import Settings + +ALGORITHM = "HS256" + + +class InvalidToken(Exception): + pass + + +def create_token(payload: dict, key: str): + now = datetime.now(tz=timezone.utc) + data = { + "iat": now, + "exp": now + timedelta(hours=24), + **payload, + } + return jwt.encode(data, key, algorithm=ALGORITHM) + + +def extract_payload(token: str, key: str): + try: + return jwt.decode(token, key, algorithms=[ALGORITHM]) + except PyJWTError as err: + raise InvalidToken(str(err)) + + +def is_admin( + settings: Settings, authorization: Optional[str] = None +): + if authorization is None: + return False + + partition_key = ( + "Bearer" if "Bearer" in authorization else "bearer" + ) + + *dontcare, token = authorization.partition( + f"{partition_key} " + ) + token = token.strip() + + try: + payload = extract_payload(token, settings.secret_key) + except InvalidToken: + return False + else: + return payload.get("role") == "admin" diff --git a/ch14/api_code/dummy_data.py b/ch14/api_code/dummy_data.py new file mode 100644 index 0000000..88e2bfb --- /dev/null +++ b/ch14/api_code/dummy_data.py @@ -0,0 +1,182 @@ +# api_code/dummy_data.py +from datetime import datetime, timedelta, timezone +from pathlib import Path +from random import choice, randint, random + +from api.models import ( + Base, + Classes, + Roles, + Station, + Ticket, + Train, + User, +) +from faker import Faker +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +DB_URL = "sqlite:///train.db" +engine = create_engine(DB_URL) + + +def new_db(filename): + db_file = Path(filename) + db_file.unlink(missing_ok=True) + + # then create a fresh DB + Base.metadata.create_all(engine) + + +if __name__ == "__main__": + + new_db("train.db") + + Session = sessionmaker(bind=engine) + session = Session() + + fake = Faker() + + # USERS + + NUM_USERS = 100 + NUM_TICKETS = 300 + NUM_TRAINS = 300 + + class_choices = [c for c in Classes] + + users = [ + User( + id=0, + full_name="Fabrizio Romano", + email="fabrizio.romano@example.com", + password=User.hash_password("f4bPassword"), + role=Roles.admin, + ) + ] + + for user_id in range(1, NUM_USERS + 1): + users.append( + User( + id=user_id, + full_name=fake.name(), + email=fake.safe_email(), + password=User.hash_password(fake.password()), + role=Roles.passenger, + ) + ) + + session.bulk_save_objects(users) + session.commit() + + # STATIONS + + stations = [ + Station(id=0, code="ROM", country="Italy", city="Rome"), + Station(id=1, code="PAR", country="France", city="Paris"), + Station(id=2, code="LDN", country="UK", city="London"), + Station(id=3, code="KYV", country="Ukraine", city="Kyiv"), + Station( + id=4, code="STK", country="Sweden", city="Stockholm" + ), + Station( + id=5, code="WSW", country="Poland", city="Warsaw" + ), + Station( + id=6, code="MSK", country="Russia", city="Moskow" + ), + Station( + id=7, + code="AMD", + country="Netherlands", + city="Amsterdam", + ), + Station( + id=8, code="EDB", country="Scotland", city="Edinburgh" + ), + Station( + id=9, code="BDP", country="Hungary", city="Budapest" + ), + Station( + id=10, code="BCR", country="Romania", city="Bucharest" + ), + Station( + id=11, code="SFA", country="Bulgaria", city="Sofia" + ), + ] + + session.bulk_save_objects(stations) + session.commit() + + # TRAINS + + trains = [] + now = datetime.now(tz=timezone.utc) + HOUR = 60 * 60 + DAY = 24 * HOUR + TEN_DAYS = 10 * DAY + + for train_id in range(NUM_TRAINS): + station_ids = list(range(len(stations))) + station_from = choice(station_ids) + station_ids.remove(station_from) + station_to = choice(station_ids) + + name = f"{stations[station_from].city} -> {stations[station_to].city}" + departure = now + timedelta( + seconds=randint(-TEN_DAYS, TEN_DAYS) + ) + arrival = departure + timedelta( + seconds=randint(HOUR, DAY) + ) + + trains.append( + Train( + id=train_id, + name=name, + station_from_id=station_from, + station_to_id=station_to, + departs_at=departure, + arrives_at=arrival, + first_class=randint(0, 5), + second_class=randint(1, 10), + seats_per_car=choice([10, 25, 40]), + ) + ) + + session.bulk_save_objects(trains) + session.commit() + + # TICKETS + + tickets = [] + classes = [c for c in Classes] + MIN_PRICE = 5 + MAX_PRICE = 200 + + for ticket_id in range(NUM_TICKETS): + price = round( + float(randint(MIN_PRICE, MAX_PRICE)) + + randint(0, 1) * random(), + 2, + ) + tickets.append( + Ticket( + id=ticket_id, + created_at=now + + timedelta(seconds=randint(-TEN_DAYS, -HOUR)), + user_id=choice( + range(len(users) - 1) + ), # last user has no tickets + train_id=choice( + range(len(trains) - 1) + ), # last train has no tickets + price=price, + car_class=choice(classes), + ) + ) + + session.bulk_save_objects(tickets) + session.commit() + + print("done") diff --git a/ch14/api_code/main.py b/ch14/api_code/main.py new file mode 100644 index 0000000..154f03e --- /dev/null +++ b/ch14/api_code/main.py @@ -0,0 +1,20 @@ +# api_code/main.py +from api import admin, config, stations, tickets, trains, users +from fastapi import FastAPI + +settings = config.Settings() + +app = FastAPI() + +app.include_router(admin.router) +app.include_router(stations.router) +app.include_router(trains.router) +app.include_router(users.router) +app.include_router(tickets.router) + + +@app.get("/") +def root(): + return { + "message": f"Welcome to version {settings.api_version} of our API" + } diff --git a/ch14/api_code/queries.md b/ch14/api_code/queries.md new file mode 100644 index 0000000..f910bc4 --- /dev/null +++ b/ch14/api_code/queries.md @@ -0,0 +1,125 @@ +# HTTP queries + +These are example queries that exercise the API. + +Try all of them, especially those that create or delete resources, +should be tried twice, consecutively, so you can also see the error +messages that the API will return. + + *Note*: You must install [httpie](https://httpie.io) to run the + queries below. + + +## Root + +### GET + + http http://localhost:8000 + + +## Users + +### GET + + http http://localhost:8000/users + + http http://localhost:8000/users/0 + + http http://localhost:8000/users/0/tickets + +### POST + + http POST http://localhost:8000/users full_name="John Doe" email="john.doe@example.com" password="johndoe" role="passenger" + + http POST http://localhost:8000/users/authenticate email="fabrizio.romano@example.com" password="f4bPassword" + + http POST http://localhost:8000/users/validate_token token="..." + +### PUT + + http PUT http://localhost:8000/users/101 full_name="Fabrizio Romano" email="fab109@example.com" password="something" role="admin" + +Also available partial updates: + + http PUT http://localhost:8000/users/101 role="passenger" + +### DELETE + + http DELETE http://localhost:8000/users/101 + + +## Stations + +### GET + + http http://localhost:8000/stations + + http http://localhost:8000/stations?code=LDN + + http http://localhost:8000/stations/0 + + http http://localhost:8000/stations/0/departures + + http http://localhost:8000/stations/0/arrivals + +### POST + + http POST http://localhost:8000/stations code=TMP country=Temporary-Country city=tmp-city + +### PUT + + http PUT http://localhost:8000/stations/12 code=SMC country=Some-Country city=Some-city + +Also available partial updates: + + http PUT http://localhost:8000/stations/12 code=xxx + +### DELETE + + http DELETE http://localhost:8000/stations/12 + + +## Trains + +### GET + + http http://localhost:8000/trains + + http http://localhost:8000/trains?station_from_code=BCR + http http://localhost:8000/trains?station_to_code=STK + http "http://localhost:8000/trains?station_from_code=STK&station_to_code=AMD" + http "http://localhost:8000/trains?station_from_code=STK&station_to_code=AMD&include_all=True" + + http http://localhost:8000/trains/0 + +### POST + + http POST http://localhost:8000/trains name="Pendolino" first_class=2 second_class=4 seats_per_car=8 station_from_id=0 station_to_id=3 arrives_at="2021-08-18T11:33:20" departs_at="2021-08-18T09:55:20" + +### DELETE + + http DELETE http://localhost:8000/trains/300 + + +## Tickets + +### GET + + http http://localhost:8000/tickets + + http http://localhost:8000/tickets/0 + +### POST + + http POST http://localhost:8000/tickets user_id=0 train_id=0 price=19.84 car_class="first" + +### DELETE + + http DELETE http://localhost:8000/tickets/300 + + +## Admin + +### GET + + http DELETE http://localhost:8000/admin/stations/10 Authorization:"Bearer admin.token.here" diff --git a/ch14/api_code/train.db b/ch14/api_code/train.db new file mode 100644 index 0000000000000000000000000000000000000000..f44ae6110374ac9121f85fd1ca688b01cf6bc201 GIT binary patch literal 122880 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

u}7Hz)c3(c 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 WxSV;agy5Nl><>E2?s%xPhyMY-{PhI@ literal 0 HcmV?d00001 diff --git a/ch14/apic/apic/__init__.py b/ch14/apic/apic/__init__.py new file mode 100644 index 0000000..7279967 --- /dev/null +++ b/ch14/apic/apic/__init__.py @@ -0,0 +1 @@ +# apic/apic/__init__.py diff --git a/ch14/apic/apic/asgi.py b/ch14/apic/apic/asgi.py new file mode 100644 index 0000000..5636e4e --- /dev/null +++ b/ch14/apic/apic/asgi.py @@ -0,0 +1,17 @@ +# apic/apic/asgi.py +""" +ASGI config for apic project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "apic.settings") + +application = get_asgi_application() diff --git a/ch14/apic/apic/settings.py b/ch14/apic/apic/settings.py new file mode 100644 index 0000000..4a0db1f --- /dev/null +++ b/ch14/apic/apic/settings.py @@ -0,0 +1,132 @@ +# apic/apic/settings.py +""" +Django settings for apic project. + +Generated by 'django-admin startproject' using Django 3.2.6. + +For more information on this file, see +https://docs.djangoproject.com/en/3.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/3.2/ref/settings/ +""" + +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "django-insecure-98u5ntukr@mo0e5c*ve+8bk5$i3lr+4n4gc^@=b-7c*j_lyxa(" + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + "rails.apps.RailsConfig", + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "apic.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "apic.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/3.2/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + + +# Password validation +# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/3.2/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/3.2/howto/static-files/ + +STATIC_URL = "/static/" + +# Default primary key field type +# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + + +# API Settings + +BASE_API_URL = "http://localhost:8000" diff --git a/ch14/apic/apic/urls.py b/ch14/apic/apic/urls.py new file mode 100644 index 0000000..0504846 --- /dev/null +++ b/ch14/apic/apic/urls.py @@ -0,0 +1,23 @@ +# apic/apic/urls.py +"""apic URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/3.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import include, path + +urlpatterns = [ + path("", include("rails.urls")), + path("admin/", admin.site.urls), +] diff --git a/ch14/apic/apic/wsgi.py b/ch14/apic/apic/wsgi.py new file mode 100644 index 0000000..fbd65e3 --- /dev/null +++ b/ch14/apic/apic/wsgi.py @@ -0,0 +1,17 @@ +# apic/apic/wsgi.py +""" +WSGI config for apic project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "apic.settings") + +application = get_wsgi_application() diff --git a/ch14/apic/manage.py b/ch14/apic/manage.py new file mode 100644 index 0000000..840e1fc --- /dev/null +++ b/ch14/apic/manage.py @@ -0,0 +1,27 @@ +# apic/manage.py +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault( + "DJANGO_SETTINGS_MODULE", "apic.settings" + ) + try: + from django.core.management import ( + execute_from_command_line, + ) + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/ch14/apic/rails/__init__.py b/ch14/apic/rails/__init__.py new file mode 100644 index 0000000..9c4b09f --- /dev/null +++ b/ch14/apic/rails/__init__.py @@ -0,0 +1 @@ +# apic/rails/__init__.py diff --git a/ch14/apic/rails/admin.py b/ch14/apic/rails/admin.py new file mode 100644 index 0000000..9b779a8 --- /dev/null +++ b/ch14/apic/rails/admin.py @@ -0,0 +1,4 @@ +# apic/rails/admin.py +from django.contrib import admin + +# Register your models here. diff --git a/ch14/apic/rails/apps.py b/ch14/apic/rails/apps.py new file mode 100644 index 0000000..2ae70aa --- /dev/null +++ b/ch14/apic/rails/apps.py @@ -0,0 +1,7 @@ +# apic/rails/apps.py +from django.apps import AppConfig + + +class RailsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "rails" diff --git a/ch14/apic/rails/forms.py b/ch14/apic/rails/forms.py new file mode 100644 index 0000000..4e93025 --- /dev/null +++ b/ch14/apic/rails/forms.py @@ -0,0 +1,9 @@ +# apic/rails/forms.py +from django import forms + + +class AuthenticateForm(forms.Form): + email = forms.EmailField(max_length=256, label="Username") + password = forms.CharField( + label="Password", widget=forms.PasswordInput + ) diff --git a/ch14/apic/rails/migrations/__init__.py b/ch14/apic/rails/migrations/__init__.py new file mode 100644 index 0000000..fd79fa5 --- /dev/null +++ b/ch14/apic/rails/migrations/__init__.py @@ -0,0 +1 @@ +# apic/rails/migrations/__init__.py diff --git a/ch14/apic/rails/models.py b/ch14/apic/rails/models.py new file mode 100644 index 0000000..e73e427 --- /dev/null +++ b/ch14/apic/rails/models.py @@ -0,0 +1,4 @@ +# apic/rails/models.py +from django.db import models + +# Create your models here. diff --git a/ch14/apic/rails/static/rails/style.css b/ch14/apic/rails/static/rails/style.css new file mode 100644 index 0000000..f52987f --- /dev/null +++ b/ch14/apic/rails/static/rails/style.css @@ -0,0 +1,29 @@ +body { + font-family: "Georgia", Times, serif; +} + +.bg_color1 { + background-color: lightsteelblue; + padding: 0.1em 0 0.1em 0.8em; +} + +.bg_color2 { + background-color:whitesmoke; + padding: 0.1em 0 0.1em 0.8em; +} + +.footer { + padding-top: 1em; +} + +a { + color: #333; +} + +a:hover { + color:steelblue; +} + +.error { + color:darkred; +} diff --git a/ch14/apic/rails/templates/rails/arrivals.html b/ch14/apic/rails/templates/rails/arrivals.html new file mode 100644 index 0000000..4338925 --- /dev/null +++ b/ch14/apic/rails/templates/rails/arrivals.html @@ -0,0 +1,54 @@ +{% extends "rails/base.html" %} + +{% block title %}Arrivals{% endblock %} + +{% block content %} + +{% if arrivals %} +

Arrivals to {{ arrivals.0.station_to.city }} ({{ arrivals.0.station_to.code }})

+{% endif %} + +{% for arv in arrivals %} + +
+

{{ arv.name }}

+

+ Departs at: {{ arv.departs_at }}
+ Arrives at: {{ arv.arrives_at }}
+ Cars: {{ arv.first_class}} FC, + {{ arv.second_class }} SC + ({{ arv.seats_per_car }} seats/car) +

+
+ +{% empty %} + + {% if error %} + +
+

Error

+

There was a problem connecting to the API.

+ {{ error }} +

+ (The above error is shown to the user as an example. + For security reasons these errors are normally hidden from the user) +

+
+ + {% else %} + +
+

There are no arrivals available at this time.

+
+ + {% endif %} + +{% endfor %} + +{% endblock %} + +{% block footer %} + +{% endblock %} diff --git a/ch14/apic/rails/templates/rails/authenticate.html b/ch14/apic/rails/templates/rails/authenticate.html new file mode 100644 index 0000000..2efd969 --- /dev/null +++ b/ch14/apic/rails/templates/rails/authenticate.html @@ -0,0 +1,21 @@ +{% extends "rails/base.html" %} + +{% block title %}Authentication{% endblock %} + +{% block content %} + +

Authentication

+ +
+ {% csrf_token %} + {{ form.as_p }} + +
+ +{% endblock %} + +{% block footer %} + +{% endblock %} diff --git a/ch14/apic/rails/templates/rails/authenticate.result.html b/ch14/apic/rails/templates/rails/authenticate.result.html new file mode 100644 index 0000000..73d23e9 --- /dev/null +++ b/ch14/apic/rails/templates/rails/authenticate.result.html @@ -0,0 +1,38 @@ +{% extends "rails/base.html" %} + +{% block title %}Authentication Result{% endblock %} + +{% block content %} + +{% if token %} + +

+

Your JWT token:

+ +

+ + + +{% else %} + +

We were unable to retrieve your token.

+ +{% if auth_error %} + +{{ auth_error }} + +{% endif %} + +{% endif %} + + +{% endblock %} + +{% block footer %} + +{% endblock %} diff --git a/ch14/apic/rails/templates/rails/base.html b/ch14/apic/rails/templates/rails/base.html new file mode 100644 index 0000000..8fb45b4 --- /dev/null +++ b/ch14/apic/rails/templates/rails/base.html @@ -0,0 +1,17 @@ +{% load static %} + + + +{% block title %}Hello{% endblock %} + + + + + {% block content %} + {% endblock %} + + {% block footer %} + {% endblock %} + + + diff --git a/ch14/apic/rails/templates/rails/departures.html b/ch14/apic/rails/templates/rails/departures.html new file mode 100644 index 0000000..0b54d2f --- /dev/null +++ b/ch14/apic/rails/templates/rails/departures.html @@ -0,0 +1,53 @@ +{% extends "rails/base.html" %} + +{% block title %}Departures{% endblock %} + +{% block content %} + +{% if departures %} +

Departures from {{ departures.0.station_from.city }} ({{ departures.0.station_from.code }})

+{% endif %} + +{% for dep in departures %} +
+

{{ dep.name }}

+

+ Departs at: {{ dep.departs_at }}
+ Arrives at: {{ dep.arrives_at }}
+ Cars: {{ dep.first_class}} FC, + {{ dep.second_class }} SC + ({{ dep.seats_per_car }} seats/car) +

+
+ +{% empty %} + + {% if error %} + +
+

Error

+

There was a problem connecting to the API.

+ {{ error }} +

+ (The above error is shown to the user as an example. + For security reasons these errors are normally hidden from the user) +

+
+ + {% else %} + +
+

There are no departures available at this time.

+
+ + {% endif %} + +{% endfor %} + +{% endblock %} + +{% block footer %} + +{% endblock %} diff --git a/ch14/apic/rails/templates/rails/index.html b/ch14/apic/rails/templates/rails/index.html new file mode 100644 index 0000000..36a2f0e --- /dev/null +++ b/ch14/apic/rails/templates/rails/index.html @@ -0,0 +1,26 @@ +{% extends "rails/base.html" %} + +{% block title %}Home{% endblock %} + +{% block content %} + + +

Wecome to our Railways website!

+ +

Please choose which page you wish to visit below.

+ +

+ Stations +

+ +

+ Users +

+ +

+ Authentication +

+ +{% endblock %} + +{% block footer %}{% endblock %} diff --git a/ch14/apic/rails/templates/rails/stations.html b/ch14/apic/rails/templates/rails/stations.html new file mode 100644 index 0000000..b2d914b --- /dev/null +++ b/ch14/apic/rails/templates/rails/stations.html @@ -0,0 +1,52 @@ +{% extends "rails/base.html" %} + +{% block title %}Stations{% endblock %} + +{% block content %} + +{% if stations %} +

Stations

+{% endif %} + +{% for station in stations %} + +
+

Id: {{ station.id }} <Code: {{ station.code }} + ({{ station.city }}, {{ station.country }})>  + Departures - + Arrivals +

+
+ +{% empty %} + + {% if error %} + +
+

Error

+

There was a problem connecting to the API.

+ {{ error }} +

+ (The above error is shown to the user as an example. + For security reasons these errors are normally hidden + from the user) +

+
+ + {% else %} + +
+

There are no stations available at this time.

+
+ + {% endif %} + +{% endfor %} + +{% endblock %} + +{% block footer %} + +{% endblock %} diff --git a/ch14/apic/rails/templates/rails/users.html b/ch14/apic/rails/templates/rails/users.html new file mode 100644 index 0000000..74e4f79 --- /dev/null +++ b/ch14/apic/rails/templates/rails/users.html @@ -0,0 +1,50 @@ +{% extends "rails/base.html" %} + +{% block title %}Users{% endblock %} + +{% block content %} + +{% if users %} +

Users

+{% endif %} + +{% for user in users %} + +
+

Id: {{ user.id }} <{{ user.full_name }} + ({{ user.email }}> + {{ user.role|capfirst }} +

+
+ +{% empty %} + + {% if error %} + +
+

Error

+

There was a problem connecting to the API.

+ {{ error }} +

+ (The above error is shown to the user as an example. + For security reasons these errors are normally hidden from the user) +

+
+ + {% else %} + +
+

There are no users available at this time.

+
+ + {% endif %} + +{% endfor %} + +{% endblock %} + +{% block footer %} + +{% endblock %} diff --git a/ch14/apic/rails/tests.py b/ch14/apic/rails/tests.py new file mode 100644 index 0000000..30b75fe --- /dev/null +++ b/ch14/apic/rails/tests.py @@ -0,0 +1,4 @@ +# apic/rails/tests.py +from django.test import TestCase + +# Create your tests here. diff --git a/ch14/apic/rails/urls.py b/ch14/apic/rails/urls.py new file mode 100644 index 0000000..bfcd96d --- /dev/null +++ b/ch14/apic/rails/urls.py @@ -0,0 +1,32 @@ +# apic/rails/urls.py +from django.urls import path + +from . import views + +urlpatterns = [ + path("", views.IndexView.as_view(), name="index"), + path( + "stations", views.StationsView.as_view(), name="stations" + ), + path( + "stations//departures", + views.DeparturesView.as_view(), + name="departures", + ), + path( + "stations//arrivals", + views.ArrivalsView.as_view(), + name="arrivals", + ), + path("users", views.UsersView.as_view(), name="users"), + path( + "authenticate", + views.AuthenticateView.as_view(), + name="authenticate", + ), + path( + "authenticate/result", + views.AuthenticateResultView.as_view(), + name="auth_result", + ), +] diff --git a/ch14/apic/rails/views.py b/ch14/apic/rails/views.py new file mode 100644 index 0000000..dfdb2ab --- /dev/null +++ b/ch14/apic/rails/views.py @@ -0,0 +1,169 @@ +# apic/rails/views.py +from datetime import datetime +from operator import itemgetter +from urllib.parse import urljoin + +import requests +from django.conf import settings +from django.urls import reverse_lazy +from django.views import generic +from requests.exceptions import RequestException + +from .forms import AuthenticateForm + + +class IndexView(generic.TemplateView): + template_name = "rails/index.html" + + +class StationsView(generic.TemplateView): + template_name = "rails/stations.html" + + def get(self, request, *args, **kwargs): + context = self.get_context_data(**kwargs) + + api_url = urljoin(settings.BASE_API_URL, "stations") + + try: + response = requests.get(api_url) + response.raise_for_status() + except RequestException as err: + context["error"] = err + else: + context["stations"] = response.json() + + return self.render_to_response(context) + + +class DeparturesView(generic.TemplateView): + template_name = "rails/departures.html" + + def get(self, request, station_id, *args, **kwargs): + context = self.get_context_data(**kwargs) + + api_url = urljoin( + settings.BASE_API_URL, + f"stations/{station_id}/departures", + ) + + try: + response = requests.get(api_url) + response.raise_for_status() + except RequestException as err: + context["error"] = err + else: + trains = prepare_trains(response.json(), "departs_at") + context["departures"] = trains + + return self.render_to_response(context) + + +class ArrivalsView(generic.TemplateView): + template_name = "rails/arrivals.html" + + def get(self, request, station_id, *args, **kwargs): + context = self.get_context_data(**kwargs) + + api_url = urljoin( + settings.BASE_API_URL, + f"stations/{station_id}/arrivals", + ) + + try: + response = requests.get(api_url) + response.raise_for_status() + except RequestException as err: + context["error"] = err + else: + trains = prepare_trains(response.json(), "arrives_at") + context["arrivals"] = trains + + return self.render_to_response(context) + + +class UsersView(generic.TemplateView): + template_name = "rails/users.html" + + def get(self, request, *args, **kwargs): + context = self.get_context_data(**kwargs) + + api_url = urljoin( + settings.BASE_API_URL, + "users", + ) + + try: + response = requests.get(api_url) + response.raise_for_status() + except RequestException as err: + context["error"] = err + else: + context["users"] = response.json() + + return self.render_to_response(context) + + +class AuthenticateView(generic.FormView): + template_name = "rails/authenticate.html" + success_url = reverse_lazy("auth_result") + form_class = AuthenticateForm + + def form_valid(self, form): + data = form.cleaned_data + self.api_authenticate(data["email"], data["password"]) + # leave this as final instruction as it will just perform the redir. + return super().form_valid(form) + + def api_authenticate(self, email, password): + api_url = urljoin( + settings.BASE_API_URL, + f"users/authenticate", + ) + + payload = { + "email": email, + "password": password, + } + + try: + response = requests.post(api_url, json=payload) + response.raise_for_status() + except RequestException as err: + self.set_session_data("auth_error", str(err)) + else: + key = "token" if response.ok else "auth_error" + self.set_session_data(key, response.json()) + + def set_session_data(self, key, data): + self.request.session[key] = data + + +class AuthenticateResultView(generic.TemplateView): + template_name = "rails/authenticate.result.html" + + def get(self, request, *args, **kwargs): + context = self.get_context_data(**kwargs) + context["token"] = request.session.pop("token", None) + context["auth_error"] = request.session.pop( + "auth_error", None + ) + return self.render_to_response(context) + + +def prepare_trains(trains: list[dict], key: str): + return list( + map( + parse_datetimes, + sorted(trains, key=itemgetter(key)), + ) + ) + + +def parse_datetimes(train: dict): + train["arrives_at"] = datetime.fromisoformat( + train["arrives_at"] + ) + train["departs_at"] = datetime.fromisoformat( + train["departs_at"] + ) + return train diff --git a/ch14/pyproject.toml b/ch14/pyproject.toml new file mode 100644 index 0000000..85e2030 --- /dev/null +++ b/ch14/pyproject.toml @@ -0,0 +1,6 @@ +[tool.black] +line-length = 66 + +[tool.isort] +atomic=true +line_length=66 diff --git a/ch14/requirements/dev.in b/ch14/requirements/dev.in new file mode 100644 index 0000000..215984e --- /dev/null +++ b/ch14/requirements/dev.in @@ -0,0 +1,5 @@ +jupyterlab +pdbpp +faker +isort +black diff --git a/ch14/requirements/dev.txt b/ch14/requirements/dev.txt new file mode 100644 index 0000000..5b75b07 --- /dev/null +++ b/ch14/requirements/dev.txt @@ -0,0 +1,256 @@ +# +# This file is autogenerated by pip-compile with python 3.9 +# To update, run: +# +# pip-compile dev.in +# +anyio==3.3.0 + # via jupyter-server +appdirs==1.4.4 + # via black +appnope==0.1.2 + # via + # ipykernel + # ipython +argon2-cffi==20.1.0 + # via + # jupyter-server + # notebook +attrs==21.2.0 + # via jsonschema +babel==2.9.1 + # via jupyterlab-server +backcall==0.2.0 + # via ipython +black==21.7b0 + # via -r dev.in +bleach==4.0.0 + # via nbconvert +certifi==2021.5.30 + # via requests +cffi==1.14.6 + # via argon2-cffi +charset-normalizer==2.0.4 + # via requests +click==8.0.1 + # via black +debugpy==1.4.1 + # via ipykernel +decorator==5.0.9 + # via ipython +defusedxml==0.7.1 + # via nbconvert +entrypoints==0.3 + # via + # jupyter-client + # jupyterlab-server + # nbconvert +faker==8.12.0 + # via -r dev.in +fancycompleter==0.9.1 + # via pdbpp +idna==3.2 + # via + # anyio + # requests +ipykernel==6.2.0 + # via notebook +ipython==7.26.0 + # via + # ipykernel + # jupyterlab +ipython-genutils==0.2.0 + # via + # jupyter-server + # nbformat + # notebook + # traitlets +isort==5.9.3 + # via -r dev.in +jedi==0.18.0 + # via ipython +jinja2==3.0.1 + # via + # jupyter-server + # jupyterlab + # jupyterlab-server + # nbconvert + # notebook +json5==0.9.6 + # via jupyterlab-server +jsonschema==3.2.0 + # via + # jupyterlab-server + # nbformat +jupyter-client==7.0.1 + # via + # ipykernel + # jupyter-server + # nbclient + # notebook +jupyter-core==4.7.1 + # via + # jupyter-client + # jupyter-server + # jupyterlab + # nbconvert + # nbformat + # notebook +jupyter-server==1.10.2 + # via + # jupyterlab + # jupyterlab-server + # nbclassic +jupyterlab==3.1.7 + # via -r dev.in +jupyterlab-pygments==0.1.2 + # via nbconvert +jupyterlab-server==2.7.1 + # via jupyterlab +markupsafe==2.0.1 + # via jinja2 +matplotlib-inline==0.1.2 + # via + # ipykernel + # ipython +mistune==0.8.4 + # via nbconvert +mypy-extensions==0.4.3 + # via black +nbclassic==0.3.1 + # via jupyterlab +nbclient==0.5.4 + # via nbconvert +nbconvert==6.1.0 + # via + # jupyter-server + # notebook +nbformat==5.1.3 + # via + # jupyter-server + # nbclient + # nbconvert + # notebook +nest-asyncio==1.5.1 + # via + # jupyter-client + # nbclient +notebook==6.4.3 + # via nbclassic +packaging==21.0 + # via + # bleach + # jupyterlab + # jupyterlab-server +pandocfilters==1.4.3 + # via nbconvert +parso==0.8.2 + # via jedi +pathspec==0.9.0 + # via black +pdbpp==0.10.3 + # via -r dev.in +pexpect==4.8.0 + # via ipython +pickleshare==0.7.5 + # via ipython +prometheus-client==0.11.0 + # via + # jupyter-server + # notebook +prompt-toolkit==3.0.20 + # via ipython +ptyprocess==0.7.0 + # via + # pexpect + # terminado +pycparser==2.20 + # via cffi +pygments==2.10.0 + # via + # ipython + # jupyterlab-pygments + # nbconvert + # pdbpp +pyparsing==2.4.7 + # via packaging +pyrepl==0.9.0 + # via fancycompleter +pyrsistent==0.18.0 + # via jsonschema +python-dateutil==2.8.2 + # via + # faker + # jupyter-client +pytz==2021.1 + # via babel +pyzmq==22.2.1 + # via + # jupyter-client + # jupyter-server + # notebook +regex==2021.8.3 + # via black +requests==2.26.0 + # via + # jupyterlab-server + # requests-unixsocket +requests-unixsocket==0.2.0 + # via jupyter-server +send2trash==1.8.0 + # via + # jupyter-server + # notebook +six==1.16.0 + # via + # argon2-cffi + # bleach + # jsonschema + # python-dateutil +sniffio==1.2.0 + # via anyio +terminado==0.11.1 + # via + # jupyter-server + # notebook +testpath==0.5.0 + # via nbconvert +text-unidecode==1.3 + # via faker +tomli==1.2.1 + # via black +tornado==6.1 + # via + # ipykernel + # jupyter-client + # jupyter-server + # jupyterlab + # notebook + # terminado +traitlets==5.0.5 + # via + # ipykernel + # ipython + # jupyter-client + # jupyter-core + # jupyter-server + # matplotlib-inline + # nbclient + # nbconvert + # nbformat + # notebook +urllib3==1.26.6 + # via + # requests + # requests-unixsocket +wcwidth==0.2.5 + # via prompt-toolkit +webencodings==0.5.1 + # via bleach +websocket-client==1.2.1 + # via jupyter-server +wmctrl==0.4 + # via pdbpp + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/ch14/requirements/requirements.in b/ch14/requirements/requirements.in new file mode 100644 index 0000000..526aee1 --- /dev/null +++ b/ch14/requirements/requirements.in @@ -0,0 +1,8 @@ +django +fastapi +pydantic[email] +pyjwt[crypto] +python-dotenv +requests +sqlalchemy +uvicorn diff --git a/ch14/requirements/requirements.txt b/ch14/requirements/requirements.txt new file mode 100644 index 0000000..c4b67a9 --- /dev/null +++ b/ch14/requirements/requirements.txt @@ -0,0 +1,62 @@ +# +# This file is autogenerated by pip-compile with python 3.9 +# To update, run: +# +# pip-compile requirements.in +# +asgiref==3.4.1 + # via + # django + # uvicorn +certifi==2021.5.30 + # via requests +cffi==1.14.6 + # via cryptography +charset-normalizer==2.0.4 + # via requests +click==8.0.1 + # via uvicorn +cryptography==3.4.7 + # via pyjwt +django==3.2.6 + # via -r requirements.in +dnspython==2.1.0 + # via email-validator +email-validator==1.1.3 + # via pydantic +fastapi==0.68.0 + # via -r requirements.in +greenlet==1.1.1 + # via sqlalchemy +h11==0.12.0 + # via uvicorn +idna==3.2 + # via + # email-validator + # requests +pycparser==2.20 + # via cffi +pydantic[email]==1.8.2 + # via + # -r requirements.in + # fastapi +pyjwt[crypto]==2.1.0 + # via -r requirements.in +python-dotenv==0.19.0 + # via -r requirements.in +pytz==2021.1 + # via django +requests==2.26.0 + # via -r requirements.in +sqlalchemy==1.4.23 + # via -r requirements.in +sqlparse==0.4.1 + # via django +starlette==0.14.2 + # via fastapi +typing-extensions==3.10.0.0 + # via pydantic +urllib3==1.26.6 + # via requests +uvicorn==0.15.0 + # via -r requirements.in diff --git a/ch14/samples/api.calls/stations.txt b/ch14/samples/api.calls/stations.txt new file mode 100644 index 0000000..ff1d16d --- /dev/null +++ b/ch14/samples/api.calls/stations.txt @@ -0,0 +1,257 @@ +$ http http://localhost:8000/stations +HTTP/1.1 200 OK +content-length: 702 +content-type: application/json +date: Thu, 19 Aug 2021 22:11:10 GMT +server: uvicorn + +[ + { + "city": "Rome", + "code": "ROM", + "country": "Italy", + "id": 0 + }, + { + "city": "Paris", + "code": "PAR", + "country": "France", + "id": 1 + }, + { + "city": "London", + "code": "LDN", + "country": "UK", + "id": 2 + }, + { + "city": "Kyiv", + "code": "KYV", + "country": "Ukraine", + "id": 3 + }, + { + "city": "Stockholm", + "code": "STK", + "country": "Sweden", + "id": 4 + }, + { + "city": "Warsaw", + "code": "WSW", + "country": "Poland", + "id": 5 + }, + { + "city": "Moskow", + "code": "MSK", + "country": "Russia", + "id": 6 + }, + { + "city": "Amsterdam", + "code": "AMD", + "country": "Netherlands", + "id": 7 + }, + { + "city": "Edinburgh", + "code": "EDB", + "country": "Scotland", + "id": 8 + }, + { + "city": "Budapest", + "code": "BDP", + "country": "Hungary", + "id": 9 + }, + { + "city": "Bucharest", + "code": "BCR", + "country": "Romania", + "id": 10 + }, + { + "city": "Sofia", + "code": "SFA", + "country": "Bulgaria", + "id": 11 + } +] + + +--- + +$ http http://localhost:8000/stations?code=LDN +HTTP/1.1 200 OK +content-length: 54 +content-type: application/json +date: Fri, 20 Aug 2021 19:31:08 GMT +server: uvicorn + +[ + { + "city": "London", + "code": "LDN", + "country": "UK", + "id": 2 + } +] + +--- + +$ http http://localhost:8000/stations/3 +HTTP/1.1 200 OK +content-length: 55 +content-type: application/json +date: Fri, 20 Aug 2021 19:38:36 GMT +server: uvicorn + +{ + "city": "Kyiv", + "code": "KYV", + "country": "Ukraine", + "id": 3 +} + +--- + +$ http http://localhost:8000/stations/kyiv +HTTP/1.1 422 Unprocessable Entity +content-length: 107 +content-type: application/json +date: Fri, 20 Aug 2021 19:42:34 GMT +server: uvicorn + +{ + "detail": [ + { + "loc": [ + "path", + "station_id" + ], + "msg": "value is not a valid integer", + "type": "type_error.integer" + } + ] +} + +--- + +$ http http://localhost:8000/stations/100 +HTTP/1.1 404 Not Found +content-length: 35 +content-type: application/json +date: Fri, 20 Aug 2021 19:48:09 GMT +server: uvicorn + +{ + "detail": "Station 100 not found." +} + + +--- + +$ http POST http://localhost:8000/stations \ +code=TMP country=Temporary-Country city=tmp-city +HTTP/1.1 201 Created +content-length: 70 +content-type: application/json +date: Fri, 20 Aug 2021 20:20:34 GMT +server: uvicorn + +{ + "city": "tmp-city", + "code": "TMP", + "country": "Temporary-Country", + "id": 12 +} + +--- + +$ http POST http://localhost:8000/stations \ +country=Another-Country city=another-city +HTTP/1.1 422 Unprocessable Entity +content-length: 88 +content-type: application/json +date: Fri, 20 Aug 2021 20:23:21 GMT +server: uvicorn + +{ + "detail": [ + { + "loc": [ + "body", + "code" + ], + "msg": "field required", + "type": "value_error.missing" + } + ] +} + +--- + +$ http PUT http://localhost:8000/stations/12 \ +code=SMC country=Some-Country city=Some-city +HTTP/1.1 204 No Content +date: Fri, 20 Aug 2021 21:19:22 GMT +server: uvicorn + +--- + +$ http http://localhost:8000/stations/12 +HTTP/1.1 200 OK +content-length: 66 +content-type: application/json +date: Fri, 20 Aug 2021 21:20:39 GMT +server: uvicorn + +{ + "city": "Some-city", + "code": "SMC", + "country": "Some-Country", + "id": 12 +} + +--- + +$ http PUT http://localhost:8000/stations/12 code=xxx +HTTP/1.1 204 No Content +date: Fri, 20 Aug 2021 21:23:09 GMT +server: uvicorn + +--- + +$ http http://localhost:8000/stations/12 +HTTP/1.1 200 OK +content-length: 66 +content-type: application/json +date: Fri, 20 Aug 2021 21:23:47 GMT +server: uvicorn + +{ + "city": "Some-city", + "code": "xxx", + "country": "Some-Country", + "id": 12 +} + + +--- + +$ http DELETE http://localhost:8000/stations/12 +HTTP/1.1 204 No Content +date: Fri, 20 Aug 2021 21:33:17 GMT +server: uvicorn + +--- + +$ http DELETE http://localhost:8000/stations/12 +HTTP/1.1 404 Not Found +Transfer-Encoding: chunked +date: Fri, 20 Aug 2021 21:33:43 GMT +server: uvicorn + +--- diff --git a/ch14/samples/api.calls/users.txt b/ch14/samples/api.calls/users.txt new file mode 100644 index 0000000..505a016 --- /dev/null +++ b/ch14/samples/api.calls/users.txt @@ -0,0 +1,12 @@ +$ http POST http://localhost:8000/users/authenticate \ +email="fabrizio.romano@example.com" password="f4bPassword" +HTTP/1.1 200 OK +content-length: 201 +content-type: application/json +date: Fri, 20 Aug 2021 21:51:13 GMT +server: uvicorn + +"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE2Mjk0OTYyNzQsImV4cCI6MTYyOTU4MjY3NCwiZW1haWwiOiJmYWJyaXppby5yb21hbm9AZXhhbXBsZS5jb20iLCJyb2xlIjoiYWRtaW4ifQ.q-8m_xh2LacyxtNGHzMg2cQhgyDpmyvCr75Qb_7snYI" + +--- + diff --git a/ch14/samples/typing.examples.py b/ch14/samples/typing.examples.py new file mode 100644 index 0000000..f7c6cbb --- /dev/null +++ b/ch14/samples/typing.examples.py @@ -0,0 +1,77 @@ +# samples/typing.examples.py + + +############################################### +# This module is not supposed to be executed! # +############################################### + + +# not strongly typed language +a = 7 +b = "7" + +a + b == 14 +concatenate(a, b) == "77" + + +# dynamic typing +a = 7 +a * 2 == 14 +a = "Hello" +a * 2 == "HelloHello" + + +# other languages +String greeting = "Hello"; +int m = 7; +float pi = 3.141592; + + +# function annotations +def greet(first_name, last_name, age): + return f"Greeting {first_name} {last_name} of age {age}" + +def greet( + first_name: "First name of the person we are greeting", + last_name: "Last name of the person we are greeting", + age: "The person's age" +) -> "Returns the greeting sentence": + return f"Greeting {first_name} {last_name} of age {age}" + +def greet(first_name: str, last_name: str, age: int = 18) -> str: + return f"Greeting {first_name} {last_name} of age {age}" + + +# list +from typing import List + +def process_words(words: List[str]): + for word in words: + # do something with word + + +# dict +from typing import Dict + +def process_users(users: Dict[str, int]): + for name, age in users.items(): + # do something with name and age + + +# optional +from typing import Optional + +def greet_again(name: Optional[str] = None): + if name is not None: + print(f"Hello {name}!") + else: + print("Hey dude") + + +# custom +class Cat: + def __init__(self, name: str): + self.name = name + +def call_cat(cat: Cat): + return f"{cat.name}! Come here!"