Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rafael Cascalho #4

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
__pycache__
.git
*.md
.gitignore
2 changes: 2 additions & 0 deletions .env.database.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
MONGO_INITDB_ROOT_USERNAME=
MONGO_INITDB_ROOT_PASSWORD=
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
DB_NAME=
DB_URL=
13 changes: 13 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Notes file
notes

# dotenv files
.env
.env.database
.env.development

# bin python files
__pycache__

# Virtualenv
.venv
27 changes: 27 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
FROM python:3.7-alpine

# Added env var to disable poetry default create venv behavior
ENV POETRY_VIRTUALENVS_CREATE = false

# Installing required dependencies for the project setup and run
RUN apk add --no-cache alpine-sdk
RUN curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | python -
ENV PATH = "${PATH}:/root/.poetry/bin"

# Fixing pip version
RUN python -m pip install --upgrade pip

# Move toml file for the app folder
WORKDIR /app
COPY poetry.lock pyproject.toml /app/
RUN poetry install

# Exposes the port 5000
EXPOSE 5000

# Defining the app folder
COPY ./app /app/app
COPY ./scripts /app/scripts

# Executing the app
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "5000"]
1 change: 1 addition & 0 deletions Insomnia_2021-02-22.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"_type":"export","__export_format":4,"__export_date":"2021-02-23T00:55:19.867Z","__export_source":"insomnia.desktop.app:v2020.5.2","resources":[{"_id":"req_5cb1597224914cd1b99468e429efafd7","parentId":"wrk_91809eca89f04152af033c0be0e08f35","modified":1614040251096,"created":1614025534158,"url":"http://localhost:5000/simulate","name":"Make Simulation","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"portions\": 12,\n\t\"value\": 10000.0\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{"type":"bearer","token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiI5Mzc2MjgxNDAzMSIsImlhdCI6MTYxNDAyMTczNywiZXhwIjoxNjE0MTA4MTM3fQ._az0jcT0h701-6J9YezRyD0vjGI3MBH4D7NdYsqM_6o"},"metaSortKey":-1614025534158,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"wrk_91809eca89f04152af033c0be0e08f35","parentId":null,"modified":1614011816476,"created":1614011816476,"name":"CreditoExpress","description":"","scope":null,"_type":"workspace"},{"_id":"req_727ba48e5bf4433bad354ca047c6419d","parentId":"wrk_91809eca89f04152af033c0be0e08f35","modified":1614032536104,"created":1614011831865,"url":"http://localhost:5000/auth/login","name":"login","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n \"cpf\": \"93762814031\",\n \"cellphone\": \"71935228778\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1614011831865,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"env_bc37ec7aad7a2cc3ead160c7b15ae0b569784b5b","parentId":"wrk_91809eca89f04152af033c0be0e08f35","modified":1614011816527,"created":1614011816527,"name":"Base Environment","data":{},"dataPropertyOrder":null,"color":null,"isPrivate":false,"metaSortKey":1614011816527,"_type":"environment"},{"_id":"jar_bc37ec7aad7a2cc3ead160c7b15ae0b569784b5b","parentId":"wrk_91809eca89f04152af033c0be0e08f35","modified":1614011816528,"created":1614011816528,"name":"Default Jar","cookies":[],"_type":"cookie_jar"},{"_id":"spc_a5da6e83423941febf4a32b735abce2c","parentId":"wrk_91809eca89f04152af033c0be0e08f35","modified":1614011816481,"created":1614011816481,"fileName":"CreditoExpress","contents":"","contentType":"yaml","_type":"api_spec"}]}
11 changes: 11 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
SHELL := /bin/bash

setup-linux:
sh bin/setup.sh
python3 -m venv .venv
source .venv/bin/activate
curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | python3 -
source ${HOME}/.poetry/env

seeds:
poetry run seeder
3 changes: 3 additions & 0 deletions app/auth/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
class Unauthorized(Exception):
""" CPF and Cellphone of client don't match """
pass
15 changes: 15 additions & 0 deletions app/auth/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from pydantic import BaseModel


class User(BaseModel):
cpf: str
cellphone: str

class Config:
schema_extra = {
"example": {
"cpf": "41882728564",
"cellphone": "6526332774",
}
}

28 changes: 28 additions & 0 deletions app/auth/services.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import jwt

from datetime import datetime, timedelta

from app.infra.repositories.clients_repository import ClientsRepository
from .models import User
from .exceptions import Unauthorized


async def login(clients_repository: ClientsRepository, user: User, jwt_secret: str):
client = await clients_repository.find_by_cpf(cpf=user.cpf)

if client and user.cellphone != client['cellphone']:
raise Unauthorized()

now = datetime.now()
token_payload = {
"iat": now,
"iss": user.cpf,
"exp": now + timedelta(days=1)
}
return jwt.encode(token_payload, jwt_secret, algorithm='HS256')


async def authenticate(token: str, jwt_secret: str):
decoded = jwt.decode(token, jwt_secret, algorithms=['HS256'])
cpf = decoded['iss']
return cpf
13 changes: 13 additions & 0 deletions app/config/ioc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
class IOC:
def __init__(self) -> None:
self.ioc = {}


def add(self, name: str, dep):
self.ioc[name] = dep


def get(self, name: str):
if name not in self.ioc:
raise Exception(f'ERROR: Dependency [ {name} ] not found.')
return self.ioc[name]
3 changes: 3 additions & 0 deletions app/core/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
class FeeNotFound(Exception):
""" Fee not found for the amount of portions """
pass
61 changes: 61 additions & 0 deletions app/core/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
from typing import List
from pydantic import BaseModel, Field
from uuid import uuid4


class Client(BaseModel):
id: str = Field(default_factory=uuid4, alias='_id')
name: str
cpf: str
cellphone: str
score: int = Field(..., gt=0, lt=1000)
negative: bool

class Config:
schema_extra = {
"example": {
"name": "Roberto Filipe Figueiredo",
"cpf": "41882728564",
"cellphone": "6526332774",
"score": 300,
"negative": False,
}
}


class FeeByPortions(BaseModel):
portions: int
value: float

class Config:
schema_extra = {
"example": {
"portions": 6,
"value": 0.03
}
}


class Fee(BaseModel):
fee_type: str
fee_values: List[FeeByPortions]

class Config:
schema_extra = {
"example": {
"fee_type": "NEGATIVADO",
}
}


class Simulation(BaseModel):
value: float
portions: int

class Config:
schema_extra = {
"example": {
"value": 10000,
"portions": 6
}
}
41 changes: 41 additions & 0 deletions app/core/services.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from ..infra.repositories.clients_repository import ClientsRepository
from ..infra.repositories.fees_repository import FeesRepository

from .exceptions import FeeNotFound

MEDIUM_SCORE = 500


def check_fee_type(score: int, negative: bool) -> str:
if negative:
return 'NEGATIVADO'

return 'SCORE_ALTO' if score > MEDIUM_SCORE else 'SCORE_BAIXO'


def get_score_and_negative(client: dict):
if not client:
return 0, False

return client['score'], client['negative']


async def get_fee(
cpf: str,
portions: int,
fees_repository: FeesRepository,
clients_repository: ClientsRepository
):
client = await clients_repository.find_by_cpf(cpf=cpf)

score, negative = get_score_and_negative(client=client)
fee_type = check_fee_type(score=score, negative=negative)

fee = await fees_repository.find_by_type(fee_type=fee_type)
fee_value = next((fee_value for fee_value in fee['fee_values']
if fee_value['portions'] == portions), None)

if not fee_value:
raise FeeNotFound()

return fee_value['value']
12 changes: 12 additions & 0 deletions app/core/values.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
class SimulationRequest:
def __init__(self, fee, value, portions):
self.fee: float = fee
self.value: float = value
self.portions: int = portions

def to_dict(self):
return {
'fee': self.fee,
'value': self.value,
'portions': self.portions
}
10 changes: 10 additions & 0 deletions app/infra/api/v1/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from ...app_factory import create_app
from .auth_api import router as auth_api
from .simulations import app as simulation_api


app = create_app()

app.include_router(auth_api)

app.mount('/simulate', simulation_api)
31 changes: 31 additions & 0 deletions app/infra/api/v1/auth_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from fastapi import APIRouter, HTTPException, status, Request, Body
from fastapi.responses import JSONResponse


from ....auth.models import User
from ....auth.services import login as login_user
from ....auth.exceptions import *


router = APIRouter(prefix='/auth')

@router.post(
'/login',
tags=['auth'],
responses={
422: {'description': 'cpf or cellphone fields are missing'},
404: {'description': 'client not found'},
401: {'description': 'user info does not match'}
},
status_code=status.HTTP_200_OK
)
async def login(request: Request, user: User = Body(...)):
try:
jwt_secret = request.app.ctx.ioc.get('jwt_secret')
clients_repository = request.app.ctx.ioc.get('clients_repository')
token = await login_user(clients_repository, user=user, jwt_secret=jwt_secret)
return JSONResponse(status_code=status.HTTP_200_OK, content={ 'token': token })
except Unauthorized as ex:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='Cpf and cellphone pair are invalid')
except Exception as ex:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Internal Server Error')
55 changes: 55 additions & 0 deletions app/infra/api/v1/simulations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from fastapi import HTTPException, status, Body, Request
from fastapi.responses import JSONResponse

from ....core.exceptions import FeeNotFound
from ....core.models import Simulation
from ....core.values import SimulationRequest
from ....core.services import get_fee
from ...middlewares.auth import app
from ...external_apis.simulations_api import get_simulation


@app.post(
'/',
tags=['simulation'],
responses={
422: {'description': 'cpf or portions fields are missing'},
404: {'description': 'fee for such portions not found'},
},
status_code=status.HTTP_200_OK
)
async def simulate(request: Request, simulation: Simulation = Body(...)):
try:
cpf = request['client_cpf']
fees_repository = request.app.ctx.ioc.get('fees_repository')
clients_repository = request.app.ctx.ioc.get('clients_repository')

fee = await get_fee(
cpf=cpf,
portions=simulation.portions,
fees_repository=fees_repository,
clients_repository=clients_repository
)

simulation_request = SimulationRequest(
fee=fee,
value=simulation.value,
portions=simulation.portions
)
simulation = await get_simulation(simulation_request)

if not simulation:
return JSONResponse(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
content='Third Party Not Responding'
)

return JSONResponse(status_code=status.HTTP_200_OK, content=simulation)
except FeeNotFound as ex:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='fee amount not found')
except Exception as ex:
print(ex)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Internal Server Error'
)
31 changes: 31 additions & 0 deletions app/infra/app_factory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import secrets

from dotenv import load_dotenv
from fastapi import FastAPI

from .database import Database
from ..config.ioc import IOC
from .repositories.fees_repository import FeesRepository
from .repositories.clients_repository import ClientsRepository


load_dotenv()

SECRET_SIZE = 64
JWT_SECRET = secrets.token_hex(SECRET_SIZE)


def create_app():
app = FastAPI()
db = Database()
app.ctx = IOC()

collections = db.get_collections()
fees_repository = FeesRepository(collections['fees'])
clients_repository = ClientsRepository(collections['clients'])

app.ctx.add('fees_repository', fees_repository)
app.ctx.add('clients_repository', clients_repository)
app.ctx.add('jwt_secret', JWT_SECRET)

return app
Loading