diff --git a/.env b/.env new file mode 100644 index 0000000..c7f72ad --- /dev/null +++ b/.env @@ -0,0 +1 @@ +WHITELISTED_IPS = 52.88.246.140,52.10.220.50,54.190.46.191,52.43.82.110,127.0.0.1 \ No newline at end of file diff --git a/app.py b/app.py index e69de29..50b6f8c 100644 --- a/app.py +++ b/app.py @@ -0,0 +1,57 @@ +import logging +import uvicorn + +from database import engine, Base +from dotenv import load_dotenv +from fastapi import FastAPI, Request, status +from fastapi.responses import JSONResponse +from loggerino import timestamp_log_config +from os import getenv +from schemas import SMSMessage +from service import inbound_sms_handler, retrieve_sms_messages_by_phone_number +from sqlalchemy.orm import Session, joinedload +from uvicorn.config import LOGGING_CONFIG + + +# Create instance of FastAPI +app = FastAPI() + +# Load dotenv file (.env) +load_dotenv() + +# Load custom logger format +logger = logging.getLogger("uvicorn.error") + +# Create tables (if they don't exist) +Base.metadata.create_all(bind=engine) + +# Load whitelisted IPs +WHITELISTED_IPS = getenv("WHITELISTED_IPS").split(',') + + +@app.middleware("http") +async def validate_ip(request: Request, call_next): + ip = str(request.headers.get("x-forwarded-for", str(request.client.host))) + + if ip not in WHITELISTED_IPS: + data = {"message": f"IP {ip} is not allowed to access this resource."} + return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content=data) + + return await call_next(request) + + +@app.post("/sms-message", status_code=status.HTTP_200_OK) +async def receive_sms_message(message: SMSMessage): + inbound_sms_handler(message) + + +@app.get('/sms-message/{number}') +def get_sms_messages_by_phone_number(number: str, limit: int = 10, page: int = 1): + contact = retrieve_sms_messages_by_phone_number(number, limit, page) + return {'status': 'success', 'results': len(contact.messages), 'response': contact} + + +if __name__ == "__main__": + uvicorn.run( + app, host="0.0.0.0", port=8000, log_config=timestamp_log_config(LOGGING_CONFIG) + ) diff --git a/dtos.py b/dtos.py new file mode 100644 index 0000000..523f32b --- /dev/null +++ b/dtos.py @@ -0,0 +1,55 @@ +from models import SMSMessage as SMSDBMessage, SMSContact, SMSCost, SMSMetadata +from schemas import SMSMessage + + +def to_db(message: SMSMessage) -> SMSContact: + payload = message.data.attributes + + sms_metadata = SMSMetadata() + sms_metadata.message_callback_url = payload.message_callback_url + sms_metadata.message_encoding = payload.message_encoding + sms_metadata.message_type = payload.message_type + sms_metadata.status = payload.status + + sms_cost = SMSCost() + sms_cost.amount_display = payload.amount_display + sms_cost.amount_nanodollars = payload.amount_nanodollars + + sms_message = SMSDBMessage() + sms_message.from_number = payload.from_number + sms_message.direction = payload.direction + sms_message.is_mms = payload.is_mms + sms_message.message = payload.message + sms_message.timestamp = payload.timestamp + sms_message.cost = sms_cost + sms_message.sms_metadata = sms_metadata + + sms_contact = SMSContact() + sms_contact.phone_number = payload.to_number + sms_contact.messages = [sms_message] + return sms_contact + + +def to_db_existing_contact(message: SMSMessage) -> SMSDBMessage: + payload = message.data.attributes + + sms_metadata = SMSMetadata() + sms_metadata.message_callback_url = payload.message_callback_url + sms_metadata.message_encoding = payload.message_encoding + sms_metadata.message_type = payload.message_type + sms_metadata.status = payload.status + + sms_cost = SMSCost() + sms_cost.amount_display = payload.amount_display + sms_cost.amount_nanodollars = payload.amount_nanodollars + + sms_message = SMSDBMessage() + sms_message.from_number = payload.from_number + sms_message.direction = payload.direction + sms_message.is_mms = payload.is_mms + sms_message.message = payload.message + sms_message.timestamp = payload.timestamp + sms_message.cost = sms_cost + sms_message.sms_metadata = sms_metadata + + return sms_message \ No newline at end of file diff --git a/flowsms.db b/flowsms.db new file mode 100644 index 0000000..43c5dbc Binary files /dev/null and b/flowsms.db differ diff --git a/loggerino.py b/loggerino.py new file mode 100644 index 0000000..14bfae5 --- /dev/null +++ b/loggerino.py @@ -0,0 +1,13 @@ +from typing import Any, Dict + + +def timestamp_log_config(uvicorn_log_config: Dict[str, Any]) -> Dict[str, Any]: + datefmt = "%d-%m-%Y %H:%M:%S" + formatters = uvicorn_log_config["formatters"] + formatters["default"]["fmt"] = "%(levelprefix)s [%(asctime)s] %(message)s" + formatters["access"]["fmt"] = ( + '%(levelprefix)s [%(asctime)s] %(client_addr)s - "%(request_line)s" %(status_code)s' + ) + formatters["access"]["datefmt"] = datefmt + formatters["default"]["datefmt"] = datefmt + return uvicorn_log_config diff --git a/models.py b/models.py index 9e8c96a..834728c 100644 --- a/models.py +++ b/models.py @@ -9,10 +9,9 @@ from uuid import uuid4, UUID class SMSContact(Base): __tablename__ = 'contact' id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4) - phone_number = Column(String, nullable=False) + phone_number = Column(String, nullable=False, unique=True) messages: Mapped[List["SMSMessage"]] = relationship() created_at = Column(TIMESTAMP(timezone=True), nullable=False, server_default=func.now()) - updated_at = Column(TIMESTAMP(timezone=True), default=None, onupdate=func.now()) class SMSMessage(Base): @@ -25,9 +24,8 @@ class SMSMessage(Base): message = Column(String, nullable=False) timestamp = Column(DateTime, nullable=False) cost: Mapped["SMSCost"] = relationship(back_populates="message") - metadata: Mapped["SMSMetadata"] = relationship(back_populates="message") + sms_metadata: Mapped["SMSMetadata"] = relationship(back_populates="message") created_at = Column(TIMESTAMP(timezone=True), nullable=False, server_default=func.now()) - updated_at = Column(TIMESTAMP(timezone=True), default=None, onupdate=func.now()) class SMSCost(Base): @@ -38,7 +36,6 @@ class SMSCost(Base): message_id: Mapped[UUID] = mapped_column(ForeignKey("message.id")) message: Mapped["SMSMessage"] = relationship(back_populates="cost") created_at = Column(TIMESTAMP(timezone=True), nullable=False, server_default=func.now()) - updated_at = Column(TIMESTAMP(timezone=True), default=None, onupdate=func.now()) class SMSMetadata(Base): @@ -49,6 +46,5 @@ class SMSMetadata(Base): message_type = Column(String, nullable=False) status = Column(String, nullable=False) message_id: Mapped[UUID] = mapped_column(ForeignKey("message.id")) - message: Mapped["SMSMessage"] = relationship(back_populates="metadata") + message: Mapped["SMSMessage"] = relationship(back_populates="sms_metadata") created_at = Column(TIMESTAMP(timezone=True), nullable=False, server_default=func.now()) - updated_at = Column(TIMESTAMP(timezone=True), default=None, onupdate=func.now()) diff --git a/requirements.txt b/requirements.txt index 878ea94..5950719 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ fastapi python-dotenv sqlalchemy +uvicorn diff --git a/service.py b/service.py index e69de29..9219e44 100644 --- a/service.py +++ b/service.py @@ -0,0 +1,30 @@ +from database import get_db +from dtos import to_db, to_db_existing_contact +from models import SMSContact, SMSMessage as SMSDBMessage +from schemas import SMSMessage +from sqlalchemy.orm import joinedload + + +def inbound_sms_handler(message: SMSMessage) -> None: + db = next(get_db()) + payload = None + + sms_contact_exists = db.query(SMSContact).filter(SMSContact.phone_number == message.data.attributes.to_number).first() + + if sms_contact_exists: + payload = to_db_existing_contact(message) + payload.to_number = sms_contact_exists.id + else: + payload = to_db(message) + + db.add(payload) + db.commit() + db.refresh(payload) + + + +def retrieve_sms_messages_by_phone_number(number: str, limit: int , page: int) -> SMSContact: + db = next(get_db()) + skip = (page - 1) * limit + messages = db.query(SMSContact).filter(SMSContact.phone_number == number).options(joinedload(SMSContact.messages)).limit(limit).offset(skip).first() + return messages