From 963ffd5eaeba4a9656bb1b0cb31303e3cb62cfc4 Mon Sep 17 00:00:00 2001 From: nutaro Date: Tue, 29 Jul 2025 17:46:17 -0300 Subject: [PATCH 01/23] ignore ide files --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ee40bb3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea/ +*.pyc \ No newline at end of file From 6b76e5076a9a5fadebc40c8baa6bd1a39b094a53 Mon Sep 17 00:00:00 2001 From: nutaro Date: Tue, 29 Jul 2025 21:17:01 -0300 Subject: [PATCH 02/23] pre commit install --- .pre-commit-config.yaml | 15 +++++++++++++++ src/app.py | 0 2 files changed, 15 insertions(+) create mode 100644 .pre-commit-config.yaml create mode 100644 src/app.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..2177661 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,15 @@ +repos: + - repo: https://github.com/pycqa/flake8 + rev: '' # pick a git hash / tag to point to + hooks: + - id: flake8 + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.3.0 + hooks: + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + - repo: https://github.com/psf/black + rev: 22.10.0 + hooks: + - id: black diff --git a/src/app.py b/src/app.py new file mode 100644 index 0000000..e69de29 From 56a41e17e6870416d1d4545cccc73b1e2873bbea Mon Sep 17 00:00:00 2001 From: nutaro Date: Tue, 29 Jul 2025 21:25:39 -0300 Subject: [PATCH 03/23] basic requirements --- requirements.txt | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..01a0cfe --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +currencyapicom==0.1.1 +fastapi==0.116.1 +SQLAlchemy==2.0.42 From 105221e173c99714fa753a9559c2b3767ba48aa7 Mon Sep 17 00:00:00 2001 From: nutaro Date: Tue, 29 Jul 2025 21:26:22 -0300 Subject: [PATCH 04/23] basic project --- src/app.py | 18 ++++++++++++++++++ src/config.py | 12 ++++++++++++ src/database.py | 21 +++++++++++++++++++++ src/model.py | 24 ++++++++++++++++++++++++ src/validators.py | 26 ++++++++++++++++++++++++++ 5 files changed, 101 insertions(+) create mode 100644 src/config.py create mode 100644 src/database.py create mode 100644 src/model.py create mode 100644 src/validators.py diff --git a/src/app.py b/src/app.py index e69de29..677550e 100644 --- a/src/app.py +++ b/src/app.py @@ -0,0 +1,18 @@ +from currencyapicom import Client +from fastapi import FastAPI + +from config import Config +from validators import Convert + +app = FastAPI() + + +@app.post("/convert/") +async def convert(data: Convert): + client = Client(Config.CURRENCY_API_KEY) + exchanges = client.latest()["data"] + rate_to = exchanges[data.to_currency]["value"] + rate_from = exchanges[data.from_currency]["value"] + dollar_amount = 1 / rate_from * data.value + value = dollar_amount * rate_to + return {"value": value} diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..037fc53 --- /dev/null +++ b/src/config.py @@ -0,0 +1,12 @@ +from os import getenv + + +class Config: + + CURRENCY_API_KEY = getenv("CURRENCY_KEY") + DATABASE_DRIVER = getenv("DATABASE_DRIVER", "postgresql+psycopg2") + DATABASE_USER = getenv("DATABASE_USER", "postgres") + DATABASE_PASSWORD = getenv("DATABASE_PASSWORD", "example") + DATABASE_HOST = getenv("DATABASE_HOST", "localhost") + DATABASE_PORT = getenv("DATABASE_PORT", "5432") + DATABASE_NAME = getenv("DATABASE_NAME", "postgres") diff --git a/src/database.py b/src/database.py new file mode 100644 index 0000000..2c090a5 --- /dev/null +++ b/src/database.py @@ -0,0 +1,21 @@ +from sqlalchemy import create_engine, Engine +from sqlalchemy.orm import sessionmaker, Session + +from config import Config + + +def get_engine() -> Engine: + driver = Config.DATABASE_DRIVER + user = Config.DATABASE_USER + password = Config.DATABASE_PASSWORD + host = Config.DATABASE_HOST + port = Config.DATABASE_PORT + database = Config.DATABASE_NAME + database_url = f"{driver}://{user}:{password}@{host}:{port}/{database}" + return create_engine(database_url, echo=False) + + +def get_session() -> Session: + engine = get_engine() + Session = sessionmaker(bind=engine, expire_on_commit=False) + return Session() diff --git a/src/model.py b/src/model.py new file mode 100644 index 0000000..5ffd2a4 --- /dev/null +++ b/src/model.py @@ -0,0 +1,24 @@ +from datetime import datetime, UTC + +from sqlalchemy import Integer, String, Float, DateTime +from sqlalchemy.orm import DeclarativeBase, Mapped +from sqlalchemy.testing.schema import mapped_column + + +class Base(DeclarativeBase): + pass + + +class Transaction(Base): + + __tablename__ = "transaction" + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(Integer, nullable=False) + from_currency: Mapped[str] = mapped_column(String(3), nullable=False) + to_currency: Mapped[str] = mapped_column(String(3), nullable=False) + from_value: Mapped[float] = mapped_column(Float, nullable=False) + to_value: Mapped[float] = mapped_column(Float, nullable=False) + rate: Mapped[float] = mapped_column(Float, nullable=False) + timestamp: Mapped[datetime] = mapped_column( + DateTime, nullable=False, default=datetime.now(UTC) + ) diff --git a/src/validators.py b/src/validators.py new file mode 100644 index 0000000..512e320 --- /dev/null +++ b/src/validators.py @@ -0,0 +1,26 @@ +from pydantic import BaseModel, Field, field_validator + + +def validate_currency(value: str) -> str: + allowed_currencies = ["BRL", "USD", "EUR", "JPY"] + if value not in allowed_currencies: + raise ValueError(f"Suported currencies are {allowed_currencies}") + return value + + +class Convert(BaseModel): + + value: float = Field(ge=0, title="The value to convert") + from_currency: str + to_currency: str + user_id: int = Field(ge=0, title="User ID", description="The user ID") + + @field_validator("from_currency") + @classmethod + def validate_from_currency(cls, value: str) -> str: + return validate_currency(value) + + @field_validator("to_currency") + @classmethod + def validate_to_currency(cls, value: str) -> str: + return validate_currency(value) From a40d002689ccd4f57ad00401b641109a41278d16 Mon Sep 17 00:00:00 2001 From: nutaro Date: Tue, 29 Jul 2025 21:27:11 -0300 Subject: [PATCH 05/23] updated flake8 --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2177661..793c761 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,15 +1,15 @@ repos: - repo: https://github.com/pycqa/flake8 - rev: '' # pick a git hash / tag to point to + rev: '7.3.0' # pick a git hash / tag to point to hooks: - id: flake8 - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v2.3.0 + rev: v5.0.0 hooks: - id: check-yaml - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/psf/black - rev: 22.10.0 + rev: 25.1.0 hooks: - id: black From 1bf20191d8cedfbdc4bfe53626501ad06751c0f9 Mon Sep 17 00:00:00 2001 From: nutaro Date: Tue, 29 Jul 2025 22:39:42 -0300 Subject: [PATCH 06/23] create custom exceptions and log --- .flake8 | 5 ++++ src/app.py | 10 +------- src/database.py | 4 +-- src/exceptions.py | 16 ++++++++++++ src/logger.py | 49 +++++++++++++++++++++++++++++++++++++ src/service.py | 62 +++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 135 insertions(+), 11 deletions(-) create mode 100644 .flake8 create mode 100644 src/exceptions.py create mode 100644 src/logger.py create mode 100644 src/service.py diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..fc978a8 --- /dev/null +++ b/.flake8 @@ -0,0 +1,5 @@ +[flake8] +max-line-length = 120 +max-complexity = 12 +select = C,E,F,W,B,B950 +ignore = E203, E501, W503 diff --git a/src/app.py b/src/app.py index 677550e..3a1cf96 100644 --- a/src/app.py +++ b/src/app.py @@ -1,7 +1,5 @@ -from currencyapicom import Client from fastapi import FastAPI -from config import Config from validators import Convert app = FastAPI() @@ -9,10 +7,4 @@ @app.post("/convert/") async def convert(data: Convert): - client = Client(Config.CURRENCY_API_KEY) - exchanges = client.latest()["data"] - rate_to = exchanges[data.to_currency]["value"] - rate_from = exchanges[data.from_currency]["value"] - dollar_amount = 1 / rate_from * data.value - value = dollar_amount * rate_to - return {"value": value} + return {} diff --git a/src/database.py b/src/database.py index 2c090a5..7d14c53 100644 --- a/src/database.py +++ b/src/database.py @@ -17,5 +17,5 @@ def get_engine() -> Engine: def get_session() -> Session: engine = get_engine() - Session = sessionmaker(bind=engine, expire_on_commit=False) - return Session() + session = sessionmaker(bind=engine, expire_on_commit=False) + return session() diff --git a/src/exceptions.py b/src/exceptions.py new file mode 100644 index 0000000..5087624 --- /dev/null +++ b/src/exceptions.py @@ -0,0 +1,16 @@ +class BaseCustomException(Exception): + + def __init__(self, message) -> None: + self.message = message + + +class CurrencyAPIException(BaseCustomException): + pass + + +class FailToParseException(BaseCustomException): + pass + + +class FailToStoreDataException(BaseCustomException): + pass diff --git a/src/logger.py b/src/logger.py new file mode 100644 index 0000000..5a83174 --- /dev/null +++ b/src/logger.py @@ -0,0 +1,49 @@ +import logging +from uuid import uuid4 + +from sys import stdout + + +class SingletonMeta(type): + """ + The Singleton class can be implemented in different ways in Python. Some + possible methods include: base class, decorator, metaclass. We will use the + metaclass because it is best suited for this purpose. + """ + + _instances = {} + + def __call__(cls, *args, **kwargs): + """ + Possible changes to the value of the `__init__` argument do not affect + the returned instance. + """ + if cls not in cls._instances: + instance = super().__call__(*args, **kwargs) + cls._instances[cls] = instance + return cls._instances[cls] + + +class CustomLogger(metaclass=SingletonMeta): + + def __init__(self): + self.logger = logging.getLogger("root") + self.logger.setLevel(logging.DEBUG) + handler = logging.StreamHandler(stdout) + handler.setLevel(logging.INFO) + formatter_string = ( + f"%(levelname)s - %(asctime)s - {uuid4()} - %(name)s - %(message)s" + ) + formatter = logging.Formatter(formatter_string) + handler.setFormatter(formatter) + self.logger.addHandler(handler) + + def get_logger(self) -> logging.Logger: + return self.logger + + +if __name__ == "__main__": + logger = CustomLogger().get_logger() + logger.debug("debug message") + logger.info("info message") + logger.error("error message") diff --git a/src/service.py b/src/service.py new file mode 100644 index 0000000..37a9826 --- /dev/null +++ b/src/service.py @@ -0,0 +1,62 @@ +from currencyapicom import Client + +from config import Config +from database import get_session +from exceptions import ( + CurrencyAPIException, + FailToParseException, + FailToStoreDataException, +) +from logger import CustomLogger +from model import Transaction +from validators import Convert + + +logger = CustomLogger().get_logger() + + +class CurrencyService: + + def __init__(self) -> None: + try: + self.__client = Client(Config.CURRENCY_API_KEY) + self.__exchanges = self.__client.latest() + except Exception: + logger.exception("Failed to get currency exchange list") + raise CurrencyAPIException("Currency API is unavailable") + self._session = get_session() + + def calculate_transaction(self, data: Convert): + try: + rate_to = self.__exchanges["data"][data.to_currency]["value"] + rate_from = self.__exchanges["data"][data.from_currency]["value"] + dollar_amount = 1 / rate_from * data.value + value = dollar_amount * rate_to + return self.__build(data, rate_to, value) + except Exception: + message = f"Failed to convert {data.to_currency} to {data.from_currency}" + logger.exception(message) + raise FailToParseException(message) + + def __build(self, data: Convert, rate: float, value: float) -> Transaction: + transaction = Transaction() + transaction.user_id = data.user_id + transaction.rate = rate + transaction.from_currency = data.from_currency + transaction.to_currency = data.to_currency + transaction.from_value = data.value + transaction.to_value = value + return transaction + + def persist(self, transaction: Transaction) -> Transaction: + try: + self._session.add(transaction) + self._session.commit() + return transaction + except Exception: + message = f"Failed to persist {transaction}" + logger.exception(message) + raise FailToStoreDataException(message) + + def __del__(self) -> None: + self._session.close() From c59c2d84fdc84fe4129b9373a700c182e0a98363 Mon Sep 17 00:00:00 2001 From: nutaro Date: Tue, 29 Jul 2025 23:12:18 -0300 Subject: [PATCH 07/23] fastapi standart --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 01a0cfe..cb08589 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ currencyapicom==0.1.1 -fastapi==0.116.1 +fastapi[standard]==0.116.1 SQLAlchemy==2.0.42 From 5a6736633244d6c33e17f69a2de03717d4d93b39 Mon Sep 17 00:00:00 2001 From: nutaro Date: Tue, 29 Jul 2025 23:13:16 -0300 Subject: [PATCH 08/23] dockerfile and postgres composer --- Dockerfile | 16 ++++++++++++++++ docker-compose.yaml | 18 ++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 Dockerfile create mode 100644 docker-compose.yaml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..545e382 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.13.5-bookworm +LABEL authors="nutaro@protonmail.com" + +RUN apt update -y +RUN apt upgrade -y + +WORKDIR /opt/app + +ADD src/ . +ADD requirements.txt requirements.txt + +RUN pip install -r requirements.txt + + +EXPOSE 80 +ENTRYPOINT ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "80", "--reload"] diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..94dac21 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,18 @@ +services: + database: + image: postgres + container_name: database + environment: + POSTGRES_PASSWORD: example + ports: + - 5432:5432 + api: + container_name: api + build: + dockerfile: Dockerfile + volumes: + - ./src:/opt/app + environment: + CURRENCY_KEY: ${CURRENCY_KEY} + ports: + - 8080:80 From 62f77b91bd5dd9cf0009c0c8bc5572cec8dd54b1 Mon Sep 17 00:00:00 2001 From: nutaro Date: Tue, 29 Jul 2025 23:33:54 -0300 Subject: [PATCH 09/23] add psycopg2 and binary --- Dockerfile | 1 + requirements.txt | 2 ++ 2 files changed, 3 insertions(+) diff --git a/Dockerfile b/Dockerfile index 545e382..aed1608 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,7 @@ LABEL authors="nutaro@protonmail.com" RUN apt update -y RUN apt upgrade -y +RUN apt install libpq-dev -y WORKDIR /opt/app diff --git a/requirements.txt b/requirements.txt index cb08589..c111a56 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ currencyapicom==0.1.1 fastapi[standard]==0.116.1 +psycopg2==2.9.10 +psycopg2-binary==2.9.10 SQLAlchemy==2.0.42 From 91ebcca7a7377a91892c8bb0a4f82b49790c3f60 Mon Sep 17 00:00:00 2001 From: nutaro Date: Wed, 30 Jul 2025 11:54:04 -0300 Subject: [PATCH 10/23] added migrations --- alembic.ini | 147 ++++++++++++++++++ alembic/README | 1 + alembic/env.py | 77 +++++++++ alembic/script.py.mako | 28 ++++ .../fdaec546a42d_create_transaction_tabe.py | 44 ++++++ docker-compose.yaml | 4 +- requirements-dev.txt | 1 + src/app.py | 15 +- 8 files changed, 315 insertions(+), 2 deletions(-) create mode 100644 alembic.ini create mode 100644 alembic/README create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/fdaec546a42d_create_transaction_tabe.py create mode 100644 requirements-dev.txt diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..9da0894 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,147 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = driver://user:password@host/db_name + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..2500aa1 --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..1393c80 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,77 @@ +import os +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context +from src.model import Base + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = os.getenv("DATABASE_URL") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + url = os.getenv("DATABASE_URL") + connectable = engine_from_config( + {"sqlalchemy.url": url}, prefix="sqlalchemy.", poolclass=pool.NullPool + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/fdaec546a42d_create_transaction_tabe.py b/alembic/versions/fdaec546a42d_create_transaction_tabe.py new file mode 100644 index 0000000..bdd01a8 --- /dev/null +++ b/alembic/versions/fdaec546a42d_create_transaction_tabe.py @@ -0,0 +1,44 @@ +"""create transaction tabe + +Revision ID: fdaec546a42d +Revises: +Create Date: 2025-07-30 11:52:18.162445 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "fdaec546a42d" +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "transaction", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("from_currency", sa.String(length=3), nullable=False), + sa.Column("to_currency", sa.String(length=3), nullable=False), + sa.Column("from_value", sa.Float(), nullable=False), + sa.Column("to_value", sa.Float(), nullable=False), + sa.Column("rate", sa.Float(), nullable=False), + sa.Column("timestamp", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("transaction") + # ### end Alembic commands ### diff --git a/docker-compose.yaml b/docker-compose.yaml index 94dac21..2759e6d 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,7 +1,7 @@ services: database: image: postgres - container_name: database + container_name: currency_database environment: POSTGRES_PASSWORD: example ports: @@ -16,3 +16,5 @@ services: CURRENCY_KEY: ${CURRENCY_KEY} ports: - 8080:80 + depends_on: + - database diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..5303806 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1 @@ +alembic==1.16.4 diff --git a/src/app.py b/src/app.py index 3a1cf96..ffd435e 100644 --- a/src/app.py +++ b/src/app.py @@ -1,5 +1,6 @@ from fastapi import FastAPI +from service import CurrencyService from validators import Convert app = FastAPI() @@ -7,4 +8,16 @@ @app.post("/convert/") async def convert(data: Convert): - return {} + service = CurrencyService() + transaction = service.calculate_transaction(data) + service.persist(transaction) + return { + "transaction_id": transaction.id, + "user_id": transaction.user_id, + "from_currency": transaction.from_currency, + "to_currency": transaction.to_currency, + "from_value": data.value, + "to_value": transaction.value, + "rate": transaction.rate, + "timestamp": transaction.timestamp, + } From b31ab7dd0f2b6ceb739ed25cfc31136efcc66610 Mon Sep 17 00:00:00 2001 From: nutaro Date: Wed, 30 Jul 2025 14:08:42 -0300 Subject: [PATCH 11/23] exceptiion handler --- docker-compose.yaml | 6 ++++ src/app.py | 71 +++++++++++++++++++++++++++++++++++---------- src/exceptions.py | 9 +++++- src/repository.py | 41 ++++++++++++++++++++++++++ src/service.py | 36 ++++++++++------------- src/validators.py | 15 +++++++++- 6 files changed, 140 insertions(+), 38 deletions(-) create mode 100644 src/repository.py diff --git a/docker-compose.yaml b/docker-compose.yaml index 2759e6d..214e0f5 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -14,6 +14,12 @@ services: - ./src:/opt/app environment: CURRENCY_KEY: ${CURRENCY_KEY} + DATABASE_DRIVER: postgresql+psycopg2 + DATABASE_USER: postgres + DATABASE_PASSWORD: example + DATABASE_HOST: database + DATABASE_PORT: 5432 + DATABASE_NAME: postgres ports: - 8080:80 depends_on: diff --git a/src/app.py b/src/app.py index ffd435e..fc410ea 100644 --- a/src/app.py +++ b/src/app.py @@ -1,23 +1,62 @@ +from typing import List + from fastapi import FastAPI +from starlette.responses import JSONResponse +from starlette.requests import Request -from service import CurrencyService -from validators import Convert +from exceptions import BaseCustomException +from logger import CustomLogger +from service import TransactionService +from validators import TransactionRequest, TransactionResponse +logger = CustomLogger().get_logger() app = FastAPI() -@app.post("/convert/") -async def convert(data: Convert): - service = CurrencyService() +@app.exception_handlers(BaseCustomException) +def custom_exception_handler(request: Request, exc: BaseCustomException): + status_code = exc.status_code + message = exc.message + return JSONResponse(status_code=status_code, content={"message": message}) + + +@app.post("/transactions") +async def create_transaction(data: TransactionRequest) -> TransactionResponse: + service = TransactionService() transaction = service.calculate_transaction(data) - service.persist(transaction) - return { - "transaction_id": transaction.id, - "user_id": transaction.user_id, - "from_currency": transaction.from_currency, - "to_currency": transaction.to_currency, - "from_value": data.value, - "to_value": transaction.value, - "rate": transaction.rate, - "timestamp": transaction.timestamp, - } + date = transaction.timestamp.strftime("%Y-%m-%dT%H:%M:%S%z") + response = TransactionResponse( + transaction_id=transaction.id, + user_id=transaction.user_id, + from_currency=transaction.from_currency, + to_currency=transaction.to_currency, + from_value=data.value, + to_value=transaction.to_value, + rate=transaction.rate, + timestamp=date, + ) + return response + + +@app.get("/transactions") +async def get_transactions(user_id: int) -> List[TransactionResponse]: + service = TransactionService() + response = [] + transactions = service.get_transactions_by_user_id(user_id) + for transaction in transactions: + transaction = transaction[0] + logger.info(transaction) + date = transaction.timestamp.strftime("%Y-%m-%dT%H:%M:%S%z") + response.append( + TransactionResponse( + transaction_id=transaction.id, + user_id=transaction.user_id, + from_currency=transaction.from_currency, + to_currency=transaction.to_currency, + from_value=transaction.from_value, + to_value=transaction.to_value, + rate=transaction.rate, + timestamp=date, + ) + ) + return response diff --git a/src/exceptions.py b/src/exceptions.py index 5087624..99dfec4 100644 --- a/src/exceptions.py +++ b/src/exceptions.py @@ -1,6 +1,7 @@ class BaseCustomException(Exception): - def __init__(self, message) -> None: + def __init__(self, message, status_code: int = 500) -> None: + self.status_code = status_code self.message = message @@ -14,3 +15,9 @@ class FailToParseException(BaseCustomException): class FailToStoreDataException(BaseCustomException): pass + + +class TransactionNotFoundException(BaseCustomException): + def __init__(self, message, status_code: int = 404) -> None: + self.status_code = status_code + self.message = message diff --git a/src/repository.py b/src/repository.py new file mode 100644 index 0000000..8181391 --- /dev/null +++ b/src/repository.py @@ -0,0 +1,41 @@ +from typing import Sequence + +from sqlalchemy import select, Row + +from database import get_session +from exceptions import TransactionNotFoundException, FailToStoreDataException +from logger import CustomLogger +from model import Transaction + + +logger = CustomLogger().get_logger() + + +class TransactionRepository: + + def __init__(self) -> None: + self._session = get_session() + + def find_transactions_by_user_id( + self, user_id: int + ) -> Sequence[Row[tuple[Transaction]]]: + statement = select(Transaction).where(Transaction.user_id == user_id) + values = self._session.execute(statement).fetchall() + if not values: + msg = f"No transactions found for user_id {user_id}" + logger.warning(msg) + raise TransactionNotFoundException(msg) + return values + + def persist(self, transaction: Transaction) -> Transaction: + try: + self._session.add(transaction) + self._session.commit() + return transaction + except Exception: + message = f"Failed to persist {transaction}" + logger.exception(message) + raise FailToStoreDataException(message) + + def __del__(self): + self._session.close() diff --git a/src/service.py b/src/service.py index 37a9826..9c0dfc1 100644 --- a/src/service.py +++ b/src/service.py @@ -1,21 +1,22 @@ +from typing import Sequence + from currencyapicom import Client +from sqlalchemy import Row from config import Config -from database import get_session from exceptions import ( CurrencyAPIException, FailToParseException, - FailToStoreDataException, ) from logger import CustomLogger from model import Transaction -from validators import Convert - +from repository import TransactionRepository +from validators import TransactionRequest logger = CustomLogger().get_logger() -class CurrencyService: +class TransactionService: def __init__(self) -> None: try: @@ -24,9 +25,9 @@ def __init__(self) -> None: except Exception: logger.exception("Failed to get currency exchange list") raise CurrencyAPIException("Currency API is unavailable") - self._session = get_session() + self._repository = TransactionRepository() - def calculate_transaction(self, data: Convert): + def calculate_transaction(self, data: TransactionRequest): try: rate_to = self.__exchanges["data"][data.to_currency]["value"] rate_from = self.__exchanges["data"][data.from_currency]["value"] @@ -38,7 +39,9 @@ def calculate_transaction(self, data: Convert): logger.exception(message) raise FailToParseException(message) - def __build(self, data: Convert, rate: float, value: float) -> Transaction: + def __build( + self, data: TransactionRequest, rate: float, value: float + ) -> Transaction: transaction = Transaction() transaction.user_id = data.user_id transaction.rate = rate @@ -46,17 +49,10 @@ def __build(self, data: Convert, rate: float, value: float) -> Transaction: transaction.to_currency = data.to_currency transaction.from_value = data.value transaction.to_value = value + self._repository.persist(transaction) return transaction - def persist(self, transaction: Transaction) -> Transaction: - try: - self._session.add(transaction) - self._session.commit() - return transaction - except Exception: - message = f"Failed to persist {transaction}" - logger.exception(message) - raise FailToStoreDataException(message) - - def __del__(self) -> None: - self._session.close() + def get_transactions_by_user_id( + self, user_id: int + ) -> Sequence[Row[tuple[Transaction]]]: + return self._repository.find_transactions_by_user_id(user_id) diff --git a/src/validators.py b/src/validators.py index 512e320..eccc9bd 100644 --- a/src/validators.py +++ b/src/validators.py @@ -8,7 +8,7 @@ def validate_currency(value: str) -> str: return value -class Convert(BaseModel): +class TransactionRequest(BaseModel): value: float = Field(ge=0, title="The value to convert") from_currency: str @@ -24,3 +24,16 @@ def validate_from_currency(cls, value: str) -> str: @classmethod def validate_to_currency(cls, value: str) -> str: return validate_currency(value) + + +class TransactionResponse(BaseModel): + transaction_id: int = Field( + title="Transaction ID", description="The transaction ID" + ) + user_id: int = Field(title="User ID", description="The user ID") + from_currency: str = Field(title="From Currency", description="The From Currency") + to_currency: str = Field(title="To Currency", description="The To Currency") + from_value: float = Field(title="From Value", description="The From Value") + to_value: float = Field(title="To Value", description="The To Value") + rate: float = Field(title="Rate", description="The Rate") + timestamp: str = Field(title="Timestamp", description="The Timestamp") From 07b47b695cb7f6fe081485470ba1a984d27d5d2c Mon Sep 17 00:00:00 2001 From: nutaro Date: Wed, 30 Jul 2025 15:15:55 -0300 Subject: [PATCH 12/23] remove typo --- src/app.py | 7 +++---- src/exceptions.py | 4 +++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/app.py b/src/app.py index fc410ea..bebdfe3 100644 --- a/src/app.py +++ b/src/app.py @@ -1,8 +1,7 @@ from typing import List -from fastapi import FastAPI +from fastapi import FastAPI, Request from starlette.responses import JSONResponse -from starlette.requests import Request from exceptions import BaseCustomException from logger import CustomLogger @@ -13,8 +12,8 @@ app = FastAPI() -@app.exception_handlers(BaseCustomException) -def custom_exception_handler(request: Request, exc: BaseCustomException): +@app.exception_handler(BaseCustomException) +async def exception_handler(request: Request, exc: BaseCustomException): status_code = exc.status_code message = exc.message return JSONResponse(status_code=status_code, content={"message": message}) diff --git a/src/exceptions.py b/src/exceptions.py index 99dfec4..37daa5e 100644 --- a/src/exceptions.py +++ b/src/exceptions.py @@ -1,8 +1,9 @@ class BaseCustomException(Exception): - def __init__(self, message, status_code: int = 500) -> None: + def __init__(self, message: str, status_code: int = 500) -> None: self.status_code = status_code self.message = message + super().__init__(self.message) class CurrencyAPIException(BaseCustomException): @@ -21,3 +22,4 @@ class TransactionNotFoundException(BaseCustomException): def __init__(self, message, status_code: int = 404) -> None: self.status_code = status_code self.message = message + super().__init__(self.message, status_code) From 15dc37dd9fd84646f9ca82d9c0214e43d810b29e Mon Sep 17 00:00:00 2001 From: nutaro Date: Wed, 30 Jul 2025 17:15:28 -0300 Subject: [PATCH 13/23] test service --- src/service.py | 12 +- src/tests/__init__.py | 0 src/tests/fixtures/exchanges.json | 763 ++++++++++++++++++++++++++++++ src/tests/test_service.py | 75 +++ 4 files changed, 844 insertions(+), 6 deletions(-) create mode 100644 src/tests/__init__.py create mode 100644 src/tests/fixtures/exchanges.json create mode 100644 src/tests/test_service.py diff --git a/src/service.py b/src/service.py index 9c0dfc1..abf082d 100644 --- a/src/service.py +++ b/src/service.py @@ -20,8 +20,8 @@ class TransactionService: def __init__(self) -> None: try: - self.__client = Client(Config.CURRENCY_API_KEY) - self.__exchanges = self.__client.latest() + self._client = Client(Config.CURRENCY_API_KEY) + self._exchanges = self._client.latest() except Exception: logger.exception("Failed to get currency exchange list") raise CurrencyAPIException("Currency API is unavailable") @@ -29,17 +29,17 @@ def __init__(self) -> None: def calculate_transaction(self, data: TransactionRequest): try: - rate_to = self.__exchanges["data"][data.to_currency]["value"] - rate_from = self.__exchanges["data"][data.from_currency]["value"] + rate_to = self._exchanges["data"][data.to_currency]["value"] + rate_from = self._exchanges["data"][data.from_currency]["value"] dollar_amount = 1 / rate_from * data.value value = dollar_amount * rate_to - return self.__build(data, rate_to, value) + return self._build(data, rate_to, value) except Exception: message = f"Failed to convert {data.to_currency} to {data.from_currency}" logger.exception(message) raise FailToParseException(message) - def __build( + def _build( self, data: TransactionRequest, rate: float, value: float ) -> Transaction: transaction = Transaction() diff --git a/src/tests/__init__.py b/src/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/fixtures/exchanges.json b/src/tests/fixtures/exchanges.json new file mode 100644 index 0000000..8c3125c --- /dev/null +++ b/src/tests/fixtures/exchanges.json @@ -0,0 +1,763 @@ +{ + "meta": { + "last_updated_at": "2025-07-29T23:59:59Z" + }, + "data": { + "ADA": { + "code": "ADA", + "value": 1.2773088138 + }, + "AED": { + "code": "AED", + "value": 3.6718706506 + }, + "AFN": { + "code": "AFN", + "value": 68.7925708212 + }, + "ALL": { + "code": "ALL", + "value": 84.2700190843 + }, + "AMD": { + "code": "AMD", + "value": 382.9414346643 + }, + "ANG": { + "code": "ANG", + "value": 1.7318602083 + }, + "AOA": { + "code": "AOA", + "value": 910.1872541308 + }, + "ARB": { + "code": "ARB", + "value": 2.354536371 + }, + "ARS": { + "code": "ARS", + "value": 1291.060829503 + }, + "AUD": { + "code": "AUD", + "value": 1.535730272 + }, + "AVAX": { + "code": "AVAX", + "value": 0.0410366024 + }, + "AWG": { + "code": "AWG", + "value": 1.79 + }, + "AZN": { + "code": "AZN", + "value": 1.7 + }, + "BAM": { + "code": "BAM", + "value": 1.6932602852 + }, + "BBD": { + "code": "BBD", + "value": 2 + }, + "BDT": { + "code": "BDT", + "value": 122.6015408592 + }, + "BGN": { + "code": "BGN", + "value": 1.6880003031 + }, + "BHD": { + "code": "BHD", + "value": 0.376 + }, + "BIF": { + "code": "BIF", + "value": 2935.8687231565 + }, + "BMD": { + "code": "BMD", + "value": 1 + }, + "BNB": { + "code": "BNB", + "value": 0.0012376688 + }, + "BND": { + "code": "BND", + "value": 1.2871402092 + }, + "BOB": { + "code": "BOB", + "value": 6.9337307013 + }, + "BRL": { + "code": "BRL", + "value": 5.5748607533 + }, + "BSD": { + "code": "BSD", + "value": 1 + }, + "BTC": { + "code": "BTC", + "value": 8.4751e-06 + }, + "BTN": { + "code": "BTN", + "value": 79.7793708903 + }, + "BWP": { + "code": "BWP", + "value": 13.4026622029 + }, + "BYN": { + "code": "BYN", + "value": 3.2749193983 + }, + "BYR": { + "code": "BYR", + "value": 32749.192515257 + }, + "BZD": { + "code": "BZD", + "value": 2 + }, + "CAD": { + "code": "CAD", + "value": 1.3774902508 + }, + "CDF": { + "code": "CDF", + "value": 2876.0358382735 + }, + "CHF": { + "code": "CHF", + "value": 0.805440139 + }, + "CLF": { + "code": "CLF", + "value": 0.0244200042 + }, + "CLP": { + "code": "CLP", + "value": 970.2672604288 + }, + "CNY": { + "code": "CNY", + "value": 7.1762713296 + }, + "COP": { + "code": "COP", + "value": 4141.0409064303 + }, + "CRC": { + "code": "CRC", + "value": 505.6153899944 + }, + "CUC": { + "code": "CUC", + "value": 1 + }, + "CUP": { + "code": "CUP", + "value": 24 + }, + "CVE": { + "code": "CVE", + "value": 95.6591547592 + }, + "CZK": { + "code": "CZK", + "value": 21.2786739145 + }, + "DAI": { + "code": "DAI", + "value": 0.9992900644 + }, + "DJF": { + "code": "DJF", + "value": 177.721 + }, + "DKK": { + "code": "DKK", + "value": 6.4611211256 + }, + "DOP": { + "code": "DOP", + "value": 60.8877296815 + }, + "DOT": { + "code": "DOT", + "value": 0.2560997465 + }, + "DZD": { + "code": "DZD", + "value": 131.2405612113 + }, + "EGP": { + "code": "EGP", + "value": 48.6465979586 + }, + "ERN": { + "code": "ERN", + "value": 15 + }, + "ETB": { + "code": "ETB", + "value": 138.210201159 + }, + "ETH": { + "code": "ETH", + "value": 0.0002635416 + }, + "EUR": { + "code": "EUR", + "value": 0.865710162 + }, + "FJD": { + "code": "FJD", + "value": 2.2734302486 + }, + "FKP": { + "code": "FKP", + "value": 0.7488909362 + }, + "GBP": { + "code": "GBP", + "value": 0.7489401045 + }, + "GEL": { + "code": "GEL", + "value": 2.7028405056 + }, + "GGP": { + "code": "GGP", + "value": 0.7488909564 + }, + "GHS": { + "code": "GHS", + "value": 10.4507920861 + }, + "GIP": { + "code": "GIP", + "value": 0.7488909408 + }, + "GMD": { + "code": "GMD", + "value": 72.5983778501 + }, + "GNF": { + "code": "GNF", + "value": 8704.2597717035 + }, + "GTQ": { + "code": "GTQ", + "value": 7.6622613506 + }, + "GYD": { + "code": "GYD", + "value": 208.9176549921 + }, + "HKD": { + "code": "HKD", + "value": 7.8485210811 + }, + "HNL": { + "code": "HNL", + "value": 26.2672127354 + }, + "HRK": { + "code": "HRK", + "value": 6.3401510034 + }, + "HTG": { + "code": "HTG", + "value": 133.3585718269 + }, + "HUF": { + "code": "HUF", + "value": 345.8711335051 + }, + "IDR": { + "code": "IDR", + "value": 16379.626070196 + }, + "ILS": { + "code": "ILS", + "value": 3.3691503633 + }, + "IMP": { + "code": "IMP", + "value": 0.7488909609 + }, + "INR": { + "code": "INR", + "value": 87.0605756859 + }, + "IQD": { + "code": "IQD", + "value": 1308.3313436554 + }, + "IRR": { + "code": "IRR", + "value": 41999.077222362 + }, + "ISK": { + "code": "ISK", + "value": 123.1347839634 + }, + "JEP": { + "code": "JEP", + "value": 0.7488909804 + }, + "JMD": { + "code": "JMD", + "value": 159.5355291656 + }, + "JOD": { + "code": "JOD", + "value": 0.71 + }, + "JPY": { + "code": "JPY", + "value": 148.4774153361 + }, + "KES": { + "code": "KES", + "value": 129.4361178251 + }, + "KGS": { + "code": "KGS", + "value": 87.372134285 + }, + "KHR": { + "code": "KHR", + "value": 3999.5514326247 + }, + "KMF": { + "code": "KMF", + "value": 427.1034568217 + }, + "KPW": { + "code": "KPW", + "value": 899.9981649116 + }, + "KRW": { + "code": "KRW", + "value": 1388.6552537936 + }, + "KWD": { + "code": "KWD", + "value": 0.3055400444 + }, + "KYD": { + "code": "KYD", + "value": 0.83333 + }, + "KZT": { + "code": "KZT", + "value": 542.7083809416 + }, + "LAK": { + "code": "LAK", + "value": 21486.874359555 + }, + "LBP": { + "code": "LBP", + "value": 89557.53941403 + }, + "LKR": { + "code": "LKR", + "value": 301.9637539625 + }, + "LRD": { + "code": "LRD", + "value": 200.9339655531 + }, + "LSL": { + "code": "LSL", + "value": 17.8615531522 + }, + "LTC": { + "code": "LTC", + "value": 0.0092077954 + }, + "LTL": { + "code": "LTL", + "value": 2.9893341749 + }, + "LVL": { + "code": "LVL", + "value": 0.6084638691 + }, + "LYD": { + "code": "LYD", + "value": 5.4385609486 + }, + "MAD": { + "code": "MAD", + "value": 9.0786514647 + }, + "MATIC": { + "code": "MATIC", + "value": 4.5059953627 + }, + "MDL": { + "code": "MDL", + "value": 16.9414028296 + }, + "MGA": { + "code": "MGA", + "value": 4446.2119780922 + }, + "MKD": { + "code": "MKD", + "value": 52.6940202822 + }, + "MMK": { + "code": "MMK", + "value": 2098.5758947968 + }, + "MNT": { + "code": "MNT", + "value": 3590.4652795636 + }, + "MOP": { + "code": "MOP", + "value": 8.0609213174 + }, + "MRO": { + "code": "MRO", + "value": 356.999828 + }, + "MRU": { + "code": "MRU", + "value": 39.9502693887 + }, + "MUR": { + "code": "MUR", + "value": 45.9851661741 + }, + "MVR": { + "code": "MVR", + "value": 15.4501119363 + }, + "MWK": { + "code": "MWK", + "value": 1733.7670208007 + }, + "MXN": { + "code": "MXN", + "value": 18.7454533427 + }, + "MYR": { + "code": "MYR", + "value": 4.2346605414 + }, + "MZN": { + "code": "MZN", + "value": 63.5281900987 + }, + "NAD": { + "code": "NAD", + "value": 17.8484420323 + }, + "NGN": { + "code": "NGN", + "value": 1528.9467147629 + }, + "NIO": { + "code": "NIO", + "value": 36.818271969 + }, + "NOK": { + "code": "NOK", + "value": 10.1987718774 + }, + "NPR": { + "code": "NPR", + "value": 138.9164553657 + }, + "NZD": { + "code": "NZD", + "value": 1.6796802259 + }, + "OMR": { + "code": "OMR", + "value": 0.384260075 + }, + "OP": { + "code": "OP", + "value": 1.3769513359 + }, + "PAB": { + "code": "PAB", + "value": 0.9992301286 + }, + "PEN": { + "code": "PEN", + "value": 3.5532506011 + }, + "PGK": { + "code": "PGK", + "value": 4.0657705179 + }, + "PHP": { + "code": "PHP", + "value": 57.3131872578 + }, + "PKR": { + "code": "PKR", + "value": 282.9077302309 + }, + "PLN": { + "code": "PLN", + "value": 3.7029104708 + }, + "PYG": { + "code": "PYG", + "value": 7450.0545562251 + }, + "QAR": { + "code": "QAR", + "value": 3.639450681 + }, + "RON": { + "code": "RON", + "value": 4.3941907648 + }, + "RSD": { + "code": "RSD", + "value": 100.6995253384 + }, + "RUB": { + "code": "RUB", + "value": 81.0997483527 + }, + "RWF": { + "code": "RWF", + "value": 1444.2416531202 + }, + "SAR": { + "code": "SAR", + "value": 3.7472906326 + }, + "SBD": { + "code": "SBD", + "value": 8.3944956992 + }, + "SCR": { + "code": "SCR", + "value": 14.7968118518 + }, + "SDG": { + "code": "SDG", + "value": 601.5 + }, + "SEK": { + "code": "SEK", + "value": 9.6430611738 + }, + "SGD": { + "code": "SGD", + "value": 1.2880202326 + }, + "SHP": { + "code": "SHP", + "value": 0.748940139 + }, + "SLE": { + "code": "SLE", + "value": 22.7949882014 + }, + "SLL": { + "code": "SLL", + "value": 22732.437745298 + }, + "SOL": { + "code": "SOL", + "value": 0.0055116294 + }, + "SOS": { + "code": "SOS", + "value": 571.0273409022 + }, + "SRD": { + "code": "SRD", + "value": 36.555476781 + }, + "STD": { + "code": "STD", + "value": 21417.9899746 + }, + "STN": { + "code": "STN", + "value": 21.4179886501 + }, + "SVC": { + "code": "SVC", + "value": 8.75 + }, + "SYP": { + "code": "SYP", + "value": 13001.794355548 + }, + "SZL": { + "code": "SZL", + "value": 17.8776525232 + }, + "THB": { + "code": "THB", + "value": 32.3883056552 + }, + "TJS": { + "code": "TJS", + "value": 9.5323617755 + }, + "TMT": { + "code": "TMT", + "value": 3.5 + }, + "TND": { + "code": "TND", + "value": 2.8898205521 + }, + "TOP": { + "code": "TOP", + "value": 2.366980345 + }, + "TRY": { + "code": "TRY", + "value": 40.5549666539 + }, + "TTD": { + "code": "TTD", + "value": 6.7852312083 + }, + "TWD": { + "code": "TWD", + "value": 29.7168357367 + }, + "TZS": { + "code": "TZS", + "value": 2542.2780253697 + }, + "UAH": { + "code": "UAH", + "value": 41.9207375663 + }, + "UGX": { + "code": "UGX", + "value": 3583.919154733 + }, + "USD": { + "code": "USD", + "value": 1 + }, + "USDC": { + "code": "USDC", + "value": 0.9974532295 + }, + "USDT": { + "code": "USDT", + "value": 0.9994499922 + }, + "UYU": { + "code": "UYU", + "value": 40.0400766247 + }, + "UZS": { + "code": "UZS", + "value": 12470.455600252 + }, + "VEF": { + "code": "VEF", + "value": 12320435.183818 + }, + "VES": { + "code": "VES", + "value": 123.2043450399 + }, + "VND": { + "code": "VND", + "value": 26186.550369436 + }, + "VUV": { + "code": "VUV", + "value": 119.2823676582 + }, + "WST": { + "code": "WST", + "value": 2.7543120978 + }, + "XAF": { + "code": "XAF", + "value": 567.8480832534 + }, + "XAG": { + "code": "XAG", + "value": 0.0261779549 + }, + "XAU": { + "code": "XAU", + "value": 0.0003005579 + }, + "XCD": { + "code": "XCD", + "value": 2.7 + }, + "XDR": { + "code": "XDR", + "value": 0.7340301197 + }, + "XOF": { + "code": "XOF", + "value": 567.8480992314 + }, + "XPD": { + "code": "XPD", + "value": 0.0007938895 + }, + "XPF": { + "code": "XPF", + "value": 103.2152185945 + }, + "XPT": { + "code": "XPT", + "value": 0.0007178389 + }, + "XRP": { + "code": "XRP", + "value": 0.3193279114 + }, + "YER": { + "code": "YER", + "value": 240.2076475876 + }, + "ZAR": { + "code": "ZAR", + "value": 17.8746620272 + }, + "ZMK": { + "code": "ZMK", + "value": 9001.2 + }, + "ZMW": { + "code": "ZMW", + "value": 23.19296268 + }, + "ZWG": { + "code": "ZWG", + "value": 26.8115241754 + }, + "ZWL": { + "code": "ZWL", + "value": 66994.605885949 + } + } +} diff --git a/src/tests/test_service.py b/src/tests/test_service.py new file mode 100644 index 0000000..46756ea --- /dev/null +++ b/src/tests/test_service.py @@ -0,0 +1,75 @@ +import json +import pytest +from unittest.mock import patch, MagicMock, call + +from exceptions import CurrencyAPIException, FailToParseException +from service import TransactionService +from validators import TransactionRequest + + +@patch("service.TransactionRepository", return_value=MagicMock()) +@patch("service.Client", return_value=MagicMock()) +def test_calculate_transaction( + client: MagicMock, transaction_repository: MagicMock +) -> None: + service = TransactionService() + fixture = json.load(open("tests/fixtures/exchanges.json", "r")) + service._exchanges = fixture + service._build = MagicMock() + assert client.called + data = TransactionRequest( + from_currency="USD", to_currency="BRL", value=100, user_id=10 + ) + service.calculate_transaction(data) + assert service._build.call_args_list == [ + call(data, 5.5748607533, 557.4860753300001) + ] + + +@patch("service.TransactionRepository", return_value=MagicMock()) +@patch("service.Client", return_value=MagicMock()) +def test_should_raise_exception_when_calculation_fails( + client: MagicMock, repository: MagicMock +) -> None: + service = TransactionService() + service._exchanges = {} + service._build = MagicMock() + data = TransactionRequest( + from_currency="USD", to_currency="BRL", value=100, user_id=10 + ) + with pytest.raises(FailToParseException): + service.calculate_transaction(data) + + +@patch("service.TransactionRepository", return_value=MagicMock()) +@patch("service.Config", return_value=MagicMock()) +def test_should_raise_exception_when_use_wrong_api_key( + repository: MagicMock, config: MagicMock +) -> None: + config.CURRENCY_API_KEY = "" + with pytest.raises(CurrencyAPIException): + TransactionService() + + +@patch("service.TransactionRepository", return_value=MagicMock()) +@patch("service.Client", return_value=MagicMock()) +def test_should_build_transaction(client: MagicMock, repository: MagicMock) -> None: + service = TransactionService() + service._repository.persist = MagicMock() + data = TransactionRequest( + from_currency="USD", to_currency="BRL", value=100, user_id=10 + ) + transaction = service._build(data, 5.5748607533, 557.4860753300001) + assert transaction.user_id == 10 + assert service._repository.persist.called + assert service._repository.persist.call_args_list == [call(transaction)] + + +@patch("service.TransactionRepository", return_value=MagicMock()) +@patch("service.Client", return_value=MagicMock()) +def test_should_call_repository(client: MagicMock, repository: MagicMock) -> None: + service = TransactionService() + service._repository.find_transactions_by_user_id = MagicMock() + service.get_transactions_by_user_id(11) + assert service._repository.find_transactions_by_user_id.called + assert service._repository.find_transactions_by_user_id.call_args_list == [call(11)] From ee593d45d26807ce92aa84a5fa77bd4934807c10 Mon Sep 17 00:00:00 2001 From: nutaro Date: Thu, 31 Jul 2025 12:08:04 -0300 Subject: [PATCH 14/23] inject database as dependency --- .gitignore | 3 +- README.md | 123 ----------------------------------- requirements-dev.txt | 2 + src/app.py | 15 +++-- src/config.py | 1 + src/database.py | 11 ++-- src/repository.py | 9 +-- src/service.py | 5 +- src/tests/conftest.py | 28 ++++++++ src/tests/test_app.py | 21 ++++++ src/tests/test_repository.py | 23 +++++++ src/tests/test_service.py | 12 ++-- 12 files changed, 107 insertions(+), 146 deletions(-) create mode 100644 src/tests/conftest.py create mode 100644 src/tests/test_app.py create mode 100644 src/tests/test_repository.py diff --git a/.gitignore b/.gitignore index ee40bb3..9800dd4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .idea/ -*.pyc \ No newline at end of file +*.pyc +*.sqlite diff --git a/README.md b/README.md index d0ff8a8..e69de29 100644 --- a/README.md +++ b/README.md @@ -1,123 +0,0 @@ -# 🧪 Desafio Técnico - Backend Python (FastAPI) - -## 💸 Conversor de Moedas - -Você deverá implementar uma aplicação que permita a conversão de valores entre moedas, utilizando **Python com FastAPI** no backend. O frontend pode ser opcionalmente implementado em Vue.js ou React. - -> **Importante:** Caso você não tenha experiência com frontend, a entrega pode ser feita exclusivamente com a API. - ---- - -## 📆 Requisitos do Projeto - -### ✅ Funcionalidades Principais -- A API deve permitir a conversão entre pelo menos 4 moedas: - - BRL (Real) - - USD (Dólar Americano) - - EUR (Euro) - - JPY (Iene) - -- As taxas de câmbio devem ser obtidas da API: - - https://app.currencyapi.com/ - - Documentação: https://currencyapi.com/docs - -### 🔐 Persistência das Transações -Cada transação realizada deve ser registrada com as seguintes informações: -- ID do usuário -- Moeda de origem e destino -- Valor de origem -- Valor convertido -- Taxa de conversão -- Data/Hora UTC - -### 🔍 Endpoint de Consulta -- `GET /transactions?userId=123` - -#### Exemplo de retorno: -```json -{ - "transactionId": 42, - "userId": 123, - "fromCurrency": "USD", - "toCurrency": "BRL", - "fromValue": 100, - "toValue": 525.32, - "rate": 5.2532, - "timestamp": "2024-05-19T18:00:00Z" -} -``` - -### ❌ Casos de Erro -Deverão retornar: -- Código HTTP apropriado -- Mensagem de erro clara e objetiva - ---- - -## 🧪 Testes -- A aplicação deve conter testes unitários e de integração com `pytest` - ---- - -## 📄 README.md -Deve conter: -- Instruções para executar o projeto -- Explicação do propósito -- Principais decisões de arquitetura -- Organização das camadas (ex: routers, services, repositories, models) -- O conteúdo deve estar todo em inglês - ---- - -## 🧰 Itens Desejáveis (Diferenciais) -- Logs estruturados (ex: `loguru`, `structlog`) -- Tratamento de exceções com middlewares -- Documentação automática (Swagger já embutido no FastAPI) -- Linter (ex: `ruff`, `black`, `flake8`) -- Deploy funcional (ex: Render, Railway, Fly.io) -- CI/CD com GitHub Actions - -### Frontend (opcional) -- Vue.js 3 + TypeScript ou React + TypeScript -- TailwindCSS -- Axios -- Testes com Cypress, RTL ou Vitest - ---- - -## 🚀 Tecnologias Esperadas - -### Backend -- Python 3.10+ -- FastAPI -- SQLAlchemy 2.x ou Tortoise ORM -- PostgreSQL ou SQLite -- Pytest - ---- - -## ⭐ Perfil Desejado -- Boas práticas REST -- Arquitetura limpa e escalável -- Conhecimentos em AWS são diferenciais -- Experiência com CI/CD -- Boa comunicação e clareza de código - ---- - -## 📋 Entrega -1. Crie um repositório público no GitHub -2. Crie uma branch com seu nome em snake_case (ex: `joao_silva_souza`) -3. Suba seu código com commits organizados -4. Abra um Pull Request com: - - **Título:** `Entrega - joao_silva_souza` - - **Descrição:** Nome completo, data da entrega e observações - ---- - -## 📢 Considerações Finais -- Cite alternativas gratuitas caso use serviços pagos -- Clareza, boas práticas e organização serão avaliadas -- Pode adicionar um `THOUGHTS.md` com decisões técnicas e observações - -Boa sorte! 🚀 diff --git a/requirements-dev.txt b/requirements-dev.txt index 5303806..12b0e8f 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1 +1,3 @@ alembic==1.16.4 +httpx==0.28.1 +pytest==8.4.1 diff --git a/src/app.py b/src/app.py index bebdfe3..33eee2b 100644 --- a/src/app.py +++ b/src/app.py @@ -1,8 +1,11 @@ from typing import List from fastapi import FastAPI, Request +from fastapi.params import Depends +from sqlalchemy.orm import Session from starlette.responses import JSONResponse +from database import get_session from exceptions import BaseCustomException from logger import CustomLogger from service import TransactionService @@ -20,8 +23,10 @@ async def exception_handler(request: Request, exc: BaseCustomException): @app.post("/transactions") -async def create_transaction(data: TransactionRequest) -> TransactionResponse: - service = TransactionService() +async def create_transaction( + data: TransactionRequest, session: Session = Depends(get_session) +) -> TransactionResponse: + service = TransactionService(session) transaction = service.calculate_transaction(data) date = transaction.timestamp.strftime("%Y-%m-%dT%H:%M:%S%z") response = TransactionResponse( @@ -38,8 +43,10 @@ async def create_transaction(data: TransactionRequest) -> TransactionResponse: @app.get("/transactions") -async def get_transactions(user_id: int) -> List[TransactionResponse]: - service = TransactionService() +async def get_transactions( + user_id: int, session: Session = Depends(get_session) +) -> List[TransactionResponse]: + service = TransactionService(session) response = [] transactions = service.get_transactions_by_user_id(user_id) for transaction in transactions: diff --git a/src/config.py b/src/config.py index 037fc53..e9acd3e 100644 --- a/src/config.py +++ b/src/config.py @@ -10,3 +10,4 @@ class Config: DATABASE_HOST = getenv("DATABASE_HOST", "localhost") DATABASE_PORT = getenv("DATABASE_PORT", "5432") DATABASE_NAME = getenv("DATABASE_NAME", "postgres") + TEST_DATABASE = "" diff --git a/src/database.py b/src/database.py index 7d14c53..4a28048 100644 --- a/src/database.py +++ b/src/database.py @@ -1,5 +1,5 @@ from sqlalchemy import create_engine, Engine -from sqlalchemy.orm import sessionmaker, Session +from sqlalchemy.orm import sessionmaker from config import Config @@ -15,7 +15,10 @@ def get_engine() -> Engine: return create_engine(database_url, echo=False) -def get_session() -> Session: +def get_session(): engine = get_engine() - session = sessionmaker(bind=engine, expire_on_commit=False) - return session() + session = sessionmaker(bind=engine, expire_on_commit=False)() + try: + yield session + finally: + session.close() diff --git a/src/repository.py b/src/repository.py index 8181391..50895dc 100644 --- a/src/repository.py +++ b/src/repository.py @@ -1,8 +1,8 @@ from typing import Sequence from sqlalchemy import select, Row +from sqlalchemy.orm import Session -from database import get_session from exceptions import TransactionNotFoundException, FailToStoreDataException from logger import CustomLogger from model import Transaction @@ -13,8 +13,8 @@ class TransactionRepository: - def __init__(self) -> None: - self._session = get_session() + def __init__(self, session: Session) -> None: + self._session = session def find_transactions_by_user_id( self, user_id: int @@ -36,6 +36,3 @@ def persist(self, transaction: Transaction) -> Transaction: message = f"Failed to persist {transaction}" logger.exception(message) raise FailToStoreDataException(message) - - def __del__(self): - self._session.close() diff --git a/src/service.py b/src/service.py index abf082d..53771bb 100644 --- a/src/service.py +++ b/src/service.py @@ -2,6 +2,7 @@ from currencyapicom import Client from sqlalchemy import Row +from sqlalchemy.orm import Session from config import Config from exceptions import ( @@ -18,14 +19,14 @@ class TransactionService: - def __init__(self) -> None: + def __init__(self, session: Session) -> None: try: self._client = Client(Config.CURRENCY_API_KEY) self._exchanges = self._client.latest() except Exception: logger.exception("Failed to get currency exchange list") raise CurrencyAPIException("Currency API is unavailable") - self._repository = TransactionRepository() + self._repository = TransactionRepository(session) def calculate_transaction(self, data: TransactionRequest): try: diff --git a/src/tests/conftest.py b/src/tests/conftest.py new file mode 100644 index 0000000..e797190 --- /dev/null +++ b/src/tests/conftest.py @@ -0,0 +1,28 @@ +import pytest +from sqlalchemy import create_engine, StaticPool +from sqlalchemy.orm import sessionmaker +from starlette.testclient import TestClient + +from app import app +from app import get_session +from model import Base + + +def override_session(): + engine = create_engine( + "sqlite:///test.sqlite", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(engine) + session = sessionmaker(bind=engine)() + try: + yield session + finally: + session.close() + + +@pytest.fixture(scope="session") +def client(): + app.dependency_overrides[get_session] = override_session + return TestClient(app) diff --git a/src/tests/test_app.py b/src/tests/test_app.py new file mode 100644 index 0000000..f241600 --- /dev/null +++ b/src/tests/test_app.py @@ -0,0 +1,21 @@ +def test_transaction_post_should_fail_with_422(client) -> None: + response = client.post("/transactions", json={}) + assert response.status_code == 422 + + +def test_transaction_post_should_succeed(client) -> None: + response = client.post( + "/transactions", + json={"value": 100, "from_currency": "BRL", "to_currency": "BRL", "user_id": 1}, + ) + assert response.status_code == 200 + + +def test_transaction_get_should_succeed(client) -> None: + response = client.get("/transactions?user_id=1") + assert response.status_code == 200 + + +def test_transaction_get_should_fail_with_404(client) -> None: + response = client.get("/transactions?user_id=10") + assert response.status_code == 404 diff --git a/src/tests/test_repository.py b/src/tests/test_repository.py new file mode 100644 index 0000000..9e4df6f --- /dev/null +++ b/src/tests/test_repository.py @@ -0,0 +1,23 @@ +from unittest.mock import patch, MagicMock + +from model import Transaction +from repository import TransactionRepository + + +@patch("repository.get_session", autospec=True) +def test_find_transaction_by_user_id(get_session: MagicMock) -> None: + repository = TransactionRepository(get_session) + repository._session.execute = MagicMock() + repository.find_transactions_by_user_id(10) + assert repository._session.execute.called + + +@patch("repository.get_session", autospec=True) +def test_persist(get_session: MagicMock) -> None: + repository = TransactionRepository(get_session) + repository._session.add = MagicMock() + repository._session.commit = MagicMock() + transaction = Transaction() + transaction = repository.persist(transaction) + assert repository._session.add.called + assert repository._session.commit.called diff --git a/src/tests/test_service.py b/src/tests/test_service.py index 46756ea..d946eec 100644 --- a/src/tests/test_service.py +++ b/src/tests/test_service.py @@ -12,8 +12,8 @@ def test_calculate_transaction( client: MagicMock, transaction_repository: MagicMock ) -> None: - service = TransactionService() - fixture = json.load(open("tests/fixtures/exchanges.json", "r")) + service = TransactionService(MagicMock()) + fixture = json.load(open("src/tests/fixtures/exchanges.json", "r")) service._exchanges = fixture service._build = MagicMock() assert client.called @@ -31,7 +31,7 @@ def test_calculate_transaction( def test_should_raise_exception_when_calculation_fails( client: MagicMock, repository: MagicMock ) -> None: - service = TransactionService() + service = TransactionService(MagicMock()) service._exchanges = {} service._build = MagicMock() data = TransactionRequest( @@ -48,13 +48,13 @@ def test_should_raise_exception_when_use_wrong_api_key( ) -> None: config.CURRENCY_API_KEY = "" with pytest.raises(CurrencyAPIException): - TransactionService() + TransactionService(MagicMock()) @patch("service.TransactionRepository", return_value=MagicMock()) @patch("service.Client", return_value=MagicMock()) def test_should_build_transaction(client: MagicMock, repository: MagicMock) -> None: - service = TransactionService() + service = TransactionService(MagicMock()) service._repository.persist = MagicMock() data = TransactionRequest( from_currency="USD", to_currency="BRL", value=100, user_id=10 @@ -68,7 +68,7 @@ def test_should_build_transaction(client: MagicMock, repository: MagicMock) -> N @patch("service.TransactionRepository", return_value=MagicMock()) @patch("service.Client", return_value=MagicMock()) def test_should_call_repository(client: MagicMock, repository: MagicMock) -> None: - service = TransactionService() + service = TransactionService(MagicMock()) service._repository.find_transactions_by_user_id = MagicMock() service.get_transactions_by_user_id(11) assert service._repository.find_transactions_by_user_id.called From d8faccfbd8bc14680f46059d7f6442460aabd148 Mon Sep 17 00:00:00 2001 From: nutaro Date: Thu, 31 Jul 2025 19:19:58 -0300 Subject: [PATCH 15/23] kubernetes deployment --- .pre-commit-config.yaml | 1 - README.md | 17 +++++++++ kubernetes/deployment.yaml | 72 ++++++++++++++++++++++++++++++++++++++ src/app.py | 5 +++ 4 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 kubernetes/deployment.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 793c761..44f6d62 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,7 +6,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - - id: check-yaml - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/psf/black diff --git a/README.md b/README.md index e69de29..b77987f 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,17 @@ +### To run this project you MUST have + +* [python3](https://www.python.org/downloads/) +* [pip](https://pip.pypa.io/en/stable/installation/) +* [docker](https://docs.docker.com/engine/install/) +* [docker-compose](https://docs.docker.com/compose/install/) +* [make](https://www.gnu.org/software/make/) + + you must export this env var to run migrations in your database this example uses the database define in the docker-compose +```shell +export DATABASE_URL=postgresql+psycopg2://postgres:example@localhost:5432/postgres +``` + +you must also export the currency_api_key +```shell +export CURRENCY_KEY=your_key +``` diff --git a/kubernetes/deployment.yaml b/kubernetes/deployment.yaml new file mode 100644 index 0000000..48318a3 --- /dev/null +++ b/kubernetes/deployment.yaml @@ -0,0 +1,72 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: currency-converter-deployment + labels: + app: currency-converter +spec: + replicas: 1 + selector: + matchLabels: + app: currency-converter + template: + metadata: + labels: + app: currency-converter + spec: + containers: + - name: currency-converter + image: nutaro/currency_converter:1.0 + ports: + - containerPort: 80 + envFrom: + - configMapRef: + name: currency-converter-config + env: + - name: DATABSE_USER + valueFrom: + secretKeyRef: + name: currency-converter-secret + key: DATABASE_USER + - name: DATABASE_PASSWORD + valueFrom: + secretKeyRef: + name: currency-converter-secret + key: DATABASE_PASSWORD + - name: CURRENCY_KEY + valueFrom: + secretKeyRef: + name: currency-converter-secret + key: CURRENCY_KEY +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: currency-converter-config +data: + DATABASE_DRIVER: "postgresql+psycopg2" + DATABASE_HOST: "database" + DATABASE_PORT: "5432" + DATABASE_NAME: "postgres" +--- +apiVersion: v1 +kind: Service +metadata: + name: currency-converter-service +spec: + selector: + app: currency-converter + ports: + - port: 8879 + targetPort: 80 + type: ClusterIP +--- +apiVersion: v1 +kind: Secret +metadata: + name: currency-converter-secret +type: opaque +data: + DATABASE_USER: cG9zdGdyZXM= + DATABASE_PASSWORD: ZXhhbXBsZQ== + CURRENCY_KEY: Y3VyX2xpdmVfT3JDSGZMZ3VYbUczSkh0R1pGcVd4a1lnYWw2dkptSm93TkVqT2tjZA== diff --git a/src/app.py b/src/app.py index 33eee2b..ddbdcd3 100644 --- a/src/app.py +++ b/src/app.py @@ -22,6 +22,11 @@ async def exception_handler(request: Request, exc: BaseCustomException): return JSONResponse(status_code=status_code, content={"message": message}) +@app.get("/health") +async def health(): + return JSONResponse(status_code=200, content={}) + + @app.post("/transactions") async def create_transaction( data: TransactionRequest, session: Session = Depends(get_session) From 3e155aca3b82f84eaabb26e8b73a23cc526bafce Mon Sep 17 00:00:00 2001 From: nutaro Date: Thu, 31 Jul 2025 19:23:09 -0300 Subject: [PATCH 16/23] removing credentials --- kubernetes/deployment.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/kubernetes/deployment.yaml b/kubernetes/deployment.yaml index 48318a3..a7398f9 100644 --- a/kubernetes/deployment.yaml +++ b/kubernetes/deployment.yaml @@ -67,6 +67,6 @@ metadata: name: currency-converter-secret type: opaque data: - DATABASE_USER: cG9zdGdyZXM= - DATABASE_PASSWORD: ZXhhbXBsZQ== - CURRENCY_KEY: Y3VyX2xpdmVfT3JDSGZMZ3VYbUczSkh0R1pGcVd4a1lnYWw2dkptSm93TkVqT2tjZA== + DATABASE_USER: + DATABASE_PASSWORD: + CURRENCY_KEY: From bcd5c1c01d08e2d7d03ba88ed110f01d912f1c1b Mon Sep 17 00:00:00 2001 From: nutaro Date: Thu, 31 Jul 2025 20:34:51 -0300 Subject: [PATCH 17/23] postgres deployment --- kubernetes/deployment.yaml | 10 +-- kubernetes/postgres-deployment.yaml | 102 ++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 5 deletions(-) create mode 100644 kubernetes/postgres-deployment.yaml diff --git a/kubernetes/deployment.yaml b/kubernetes/deployment.yaml index a7398f9..797b4ba 100644 --- a/kubernetes/deployment.yaml +++ b/kubernetes/deployment.yaml @@ -45,8 +45,8 @@ metadata: name: currency-converter-config data: DATABASE_DRIVER: "postgresql+psycopg2" - DATABASE_HOST: "database" - DATABASE_PORT: "5432" + DATABASE_HOST: "postgres-service" + DATABASE_PORT: "9000" DATABASE_NAME: "postgres" --- apiVersion: v1 @@ -67,6 +67,6 @@ metadata: name: currency-converter-secret type: opaque data: - DATABASE_USER: - DATABASE_PASSWORD: - CURRENCY_KEY: + DATABASE_USER: cG9zdGdyZXM= + DATABASE_PASSWORD: ZXhhbXBsZQ== + CURRENCY_KEY: Y3VyX2xpdmVfT3JDSGZMZ3VYbUczSkh0R1pGcVd4a1lnYWw2dkptSm93TkVqT2tjZA== diff --git a/kubernetes/postgres-deployment.yaml b/kubernetes/postgres-deployment.yaml new file mode 100644 index 0000000..c6750a9 --- /dev/null +++ b/kubernetes/postgres-deployment.yaml @@ -0,0 +1,102 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: postgres +spec: + replicas: 1 + selector: + matchLabels: + app: postgres + template: + metadata: + labels: + app: postgres + spec: + containers: + - name: postgres + image: postgres:14 + imagePullPolicy: IfNotPresent + ports: + - containerPort: 5432 + envFrom: + - configMapRef: + name: postgres-config-map + env: + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: postgres-secret + key: POSTGRES_USER + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: postgres-secret + key: POSTGRES_PASSWORD + volumeMounts: + - mountPath: /var/lib/postgresql/data + name: postgres-data + volumes: + - name: postgres-data + persistentVolumeClaim: + claimName: postgres-volume-claim +--- +apiVersion: v1 +kind: Secret +metadata: + name: postgres-secret + labels: + app: postgres +type: opaque +data: + POSTGRES_USER: cG9zdGdyZXM= + POSTGRES_PASSWORD: ZXhhbXBsZQ== +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: postgres-config-map + labels: + app: postgres +data: + POSTGRES_DB: postgres +--- +apiVersion: v1 +kind: PersistentVolume +metadata: + name: postgres-volume + labels: + type: local + app: postgres +spec: + storageClassName: manual + capacity: + storage: 2Gi + accessModes: + - ReadOnlyMany + hostPath: + path: /data/postgresql +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: postgres-volume-claim + labels: + app: postgres +spec: + storageClassName: manual + accessModes: + - ReadOnlyMany + resources: + requests: + storage: 2Gi +--- +apiVersion: v1 +kind: Service +metadata: + name: postgres-service +spec: + selector: + app: postgres + ports: + - port: 9000 + targetPort: 5432 From 032ff0be7e5307efbcac64bba71ef9f9eea70dc4 Mon Sep 17 00:00:00 2001 From: nutaro Date: Thu, 31 Jul 2025 21:11:47 -0300 Subject: [PATCH 18/23] final version --- README.md | 61 ++++++++++++++++++++++++++++++++++++++ kubernetes/deployment.yaml | 2 +- requirements-dev.txt | 3 -- requirements.txt | 3 ++ src/app.py | 5 ---- 5 files changed, 65 insertions(+), 9 deletions(-) delete mode 100644 requirements-dev.txt diff --git a/README.md b/README.md index b77987f..7282be1 100644 --- a/README.md +++ b/README.md @@ -15,3 +15,64 @@ you must also export the currency_api_key ```shell export CURRENCY_KEY=your_key ``` +to run migrations: +```shell +pip install -r requirements.txt +``` +if you get a psycopg2 error it's require libpq-dev +```shell +apt install libpq-dev -y +``` +to start the containers +```shell +docker-compose up -d +``` +now run the migration +```shell +alembic upgrade head +``` +now go to [http://localhost:8080/docs](http://localhost:8080/docs) and try the api through the swagger api or +```shell +curl -X 'POST' \ + 'http://localhost:8080/transactions' \ + -H 'accept: application/json' \ + -H 'Content-Type: application/json' \ + -d '{ + "value": 10, + "from_currency": "BRL", + "to_currency": "USD", + "user_id": 1 +}' +``` +for get transaction: +```shell +curl -X 'GET' \ + 'http://localhost:4200/transactions?user_id=1' \ + -H 'accept: application/json' +``` + +### as a caveat i've included the kubernetes deployments. +just go to the deployment file on kubernetes/deployments line 72 and add you apikey base64 encoded +```shell +echo -n your_key | base64 +``` +```shell +kubectl apply -f kubernetes/ +``` +port forward the database +```shell +kubectl port-forward service/postgres-service 9000:8000 +``` +change your ENV VAR DATABASE_URL +```shell +export DATABASE_URL=postgresql+psycopg2://postgres:example@localhost:8000/postgres +``` +run the migrations +```shell +alembic upgrade head +``` +port forward the api +```shell +kubectl port-forward service/currency-converter-service 4200:8879 +``` +the service will be listen at 4200 port diff --git a/kubernetes/deployment.yaml b/kubernetes/deployment.yaml index 797b4ba..946a13e 100644 --- a/kubernetes/deployment.yaml +++ b/kubernetes/deployment.yaml @@ -69,4 +69,4 @@ type: opaque data: DATABASE_USER: cG9zdGdyZXM= DATABASE_PASSWORD: ZXhhbXBsZQ== - CURRENCY_KEY: Y3VyX2xpdmVfT3JDSGZMZ3VYbUczSkh0R1pGcVd4a1lnYWw2dkptSm93TkVqT2tjZA== + CURRENCY_KEY: diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index 12b0e8f..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,3 +0,0 @@ -alembic==1.16.4 -httpx==0.28.1 -pytest==8.4.1 diff --git a/requirements.txt b/requirements.txt index c111a56..9ee540b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,8 @@ +alembic==1.16.4 currencyapicom==0.1.1 fastapi[standard]==0.116.1 +httpx==0.28.1 psycopg2==2.9.10 psycopg2-binary==2.9.10 +pytest==8.4.1 SQLAlchemy==2.0.42 diff --git a/src/app.py b/src/app.py index ddbdcd3..33eee2b 100644 --- a/src/app.py +++ b/src/app.py @@ -22,11 +22,6 @@ async def exception_handler(request: Request, exc: BaseCustomException): return JSONResponse(status_code=status_code, content={"message": message}) -@app.get("/health") -async def health(): - return JSONResponse(status_code=200, content={}) - - @app.post("/transactions") async def create_transaction( data: TransactionRequest, session: Session = Depends(get_session) From b428eba5b0863e9556201cfedfd577733c0605f8 Mon Sep 17 00:00:00 2001 From: nutaro Date: Thu, 31 Jul 2025 21:14:52 -0300 Subject: [PATCH 19/23] upgrade image version --- kubernetes/deployment.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kubernetes/deployment.yaml b/kubernetes/deployment.yaml index 946a13e..788f17c 100644 --- a/kubernetes/deployment.yaml +++ b/kubernetes/deployment.yaml @@ -16,7 +16,7 @@ spec: spec: containers: - name: currency-converter - image: nutaro/currency_converter:1.0 + image: nutaro/currency_converter:1.0.1 ports: - containerPort: 80 envFrom: From 44c259df32f08c1a2f669f4fd972ee39cad4350f Mon Sep 17 00:00:00 2001 From: nutaro Date: Thu, 31 Jul 2025 21:19:11 -0300 Subject: [PATCH 20/23] remove make --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 7282be1..8af1241 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,6 @@ * [pip](https://pip.pypa.io/en/stable/installation/) * [docker](https://docs.docker.com/engine/install/) * [docker-compose](https://docs.docker.com/compose/install/) -* [make](https://www.gnu.org/software/make/) you must export this env var to run migrations in your database this example uses the database define in the docker-compose ```shell From 31310230a219740e2d1e2cfb33b9189e0804f128 Mon Sep 17 00:00:00 2001 From: nutaro Date: Thu, 31 Jul 2025 21:58:16 -0300 Subject: [PATCH 21/23] using dependency injection so the test can run without api --- src/app.py | 8 +++++--- src/service.py | 22 +++++++++++++--------- src/tests/conftest.py | 22 +++++++++++++++++++++- src/tests/test_repository.py | 12 +++++------- src/tests/test_service.py | 34 +++++++++------------------------- 5 files changed, 53 insertions(+), 45 deletions(-) diff --git a/src/app.py b/src/app.py index 33eee2b..343b9c9 100644 --- a/src/app.py +++ b/src/app.py @@ -8,7 +8,7 @@ from database import get_session from exceptions import BaseCustomException from logger import CustomLogger -from service import TransactionService +from service import TransactionService, get_exchanges from validators import TransactionRequest, TransactionResponse logger = CustomLogger().get_logger() @@ -24,10 +24,12 @@ async def exception_handler(request: Request, exc: BaseCustomException): @app.post("/transactions") async def create_transaction( - data: TransactionRequest, session: Session = Depends(get_session) + data: TransactionRequest, + session: Session = Depends(get_session), + exchanges: dict = Depends(get_exchanges), ) -> TransactionResponse: service = TransactionService(session) - transaction = service.calculate_transaction(data) + transaction = service.calculate_transaction(data, exchanges) date = transaction.timestamp.strftime("%Y-%m-%dT%H:%M:%S%z") response = TransactionResponse( transaction_id=transaction.id, diff --git a/src/service.py b/src/service.py index 53771bb..9956872 100644 --- a/src/service.py +++ b/src/service.py @@ -17,21 +17,25 @@ logger = CustomLogger().get_logger() +def get_exchanges() -> dict: + try: + return Client(Config.CURRENCY_API_KEY).latest() + except Exception: + logger.exception("Failed to get currency exchange list") + raise CurrencyAPIException("Currency API is unavailable") + + class TransactionService: def __init__(self, session: Session) -> None: - try: - self._client = Client(Config.CURRENCY_API_KEY) - self._exchanges = self._client.latest() - except Exception: - logger.exception("Failed to get currency exchange list") - raise CurrencyAPIException("Currency API is unavailable") self._repository = TransactionRepository(session) - def calculate_transaction(self, data: TransactionRequest): + def calculate_transaction( + self, data: TransactionRequest, exchanges: dict + ) -> Transaction: try: - rate_to = self._exchanges["data"][data.to_currency]["value"] - rate_from = self._exchanges["data"][data.from_currency]["value"] + rate_to = exchanges["data"][data.to_currency]["value"] + rate_from = exchanges["data"][data.from_currency]["value"] dollar_amount = 1 / rate_from * data.value value = dollar_amount * rate_to return self._build(data, rate_to, value) diff --git a/src/tests/conftest.py b/src/tests/conftest.py index e797190..8605cf8 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -1,10 +1,14 @@ +import json + +from unittest.mock import MagicMock + import pytest from sqlalchemy import create_engine, StaticPool from sqlalchemy.orm import sessionmaker from starlette.testclient import TestClient from app import app -from app import get_session +from app import get_session, get_exchanges from model import Base @@ -22,7 +26,23 @@ def override_session(): session.close() +def exchange() -> dict: + fixture = json.load(open("src/tests/fixtures/exchanges.json", "r")) + return fixture + + +@pytest.fixture(scope="session") +def exchanges(): + return exchange() + + @pytest.fixture(scope="session") def client(): app.dependency_overrides[get_session] = override_session + app.dependency_overrides[get_exchanges] = exchange return TestClient(app) + + +@pytest.fixture(scope="function") +def session(): + return MagicMock() diff --git a/src/tests/test_repository.py b/src/tests/test_repository.py index 9e4df6f..62cbde8 100644 --- a/src/tests/test_repository.py +++ b/src/tests/test_repository.py @@ -1,20 +1,18 @@ -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock from model import Transaction from repository import TransactionRepository -@patch("repository.get_session", autospec=True) -def test_find_transaction_by_user_id(get_session: MagicMock) -> None: - repository = TransactionRepository(get_session) +def test_find_transaction_by_user_id(session) -> None: + repository = TransactionRepository(session) repository._session.execute = MagicMock() repository.find_transactions_by_user_id(10) assert repository._session.execute.called -@patch("repository.get_session", autospec=True) -def test_persist(get_session: MagicMock) -> None: - repository = TransactionRepository(get_session) +def test_persist(session) -> None: + repository = TransactionRepository(session) repository._session.add = MagicMock() repository._session.commit = MagicMock() transaction = Transaction() diff --git a/src/tests/test_service.py b/src/tests/test_service.py index d946eec..d3a81a7 100644 --- a/src/tests/test_service.py +++ b/src/tests/test_service.py @@ -1,59 +1,44 @@ -import json import pytest from unittest.mock import patch, MagicMock, call from exceptions import CurrencyAPIException, FailToParseException -from service import TransactionService +from service import TransactionService, get_exchanges from validators import TransactionRequest @patch("service.TransactionRepository", return_value=MagicMock()) -@patch("service.Client", return_value=MagicMock()) -def test_calculate_transaction( - client: MagicMock, transaction_repository: MagicMock -) -> None: +def test_calculate_transaction(transaction_repository: MagicMock, exchanges) -> None: service = TransactionService(MagicMock()) - fixture = json.load(open("src/tests/fixtures/exchanges.json", "r")) - service._exchanges = fixture service._build = MagicMock() - assert client.called data = TransactionRequest( from_currency="USD", to_currency="BRL", value=100, user_id=10 ) - service.calculate_transaction(data) + service.calculate_transaction(data, exchanges) assert service._build.call_args_list == [ call(data, 5.5748607533, 557.4860753300001) ] @patch("service.TransactionRepository", return_value=MagicMock()) -@patch("service.Client", return_value=MagicMock()) -def test_should_raise_exception_when_calculation_fails( - client: MagicMock, repository: MagicMock -) -> None: +def test_should_raise_exception_when_calculation_fails(repository: MagicMock) -> None: service = TransactionService(MagicMock()) - service._exchanges = {} service._build = MagicMock() data = TransactionRequest( from_currency="USD", to_currency="BRL", value=100, user_id=10 ) with pytest.raises(FailToParseException): - service.calculate_transaction(data) + service.calculate_transaction(data, {}) -@patch("service.TransactionRepository", return_value=MagicMock()) @patch("service.Config", return_value=MagicMock()) -def test_should_raise_exception_when_use_wrong_api_key( - repository: MagicMock, config: MagicMock -) -> None: +def test_should_raise_exception_when_use_wrong_api_key(config: MagicMock) -> None: config.CURRENCY_API_KEY = "" with pytest.raises(CurrencyAPIException): - TransactionService(MagicMock()) + get_exchanges() @patch("service.TransactionRepository", return_value=MagicMock()) -@patch("service.Client", return_value=MagicMock()) -def test_should_build_transaction(client: MagicMock, repository: MagicMock) -> None: +def test_should_build_transaction(repository: MagicMock) -> None: service = TransactionService(MagicMock()) service._repository.persist = MagicMock() data = TransactionRequest( @@ -66,8 +51,7 @@ def test_should_build_transaction(client: MagicMock, repository: MagicMock) -> N @patch("service.TransactionRepository", return_value=MagicMock()) -@patch("service.Client", return_value=MagicMock()) -def test_should_call_repository(client: MagicMock, repository: MagicMock) -> None: +def test_should_call_repository(repository: MagicMock) -> None: service = TransactionService(MagicMock()) service._repository.find_transactions_by_user_id = MagicMock() service.get_transactions_by_user_id(11) From f89e8273ce6a8a3db775ce0784625ca200b2addd Mon Sep 17 00:00:00 2001 From: nutaro Date: Thu, 31 Jul 2025 21:58:50 -0300 Subject: [PATCH 22/23] upgrade image version --- README.md | 4 ++++ kubernetes/deployment.yaml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8af1241..885c445 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,10 @@ * [docker](https://docs.docker.com/engine/install/) * [docker-compose](https://docs.docker.com/compose/install/) +to run the tests just +```shell +pytest +``` you must export this env var to run migrations in your database this example uses the database define in the docker-compose ```shell export DATABASE_URL=postgresql+psycopg2://postgres:example@localhost:5432/postgres diff --git a/kubernetes/deployment.yaml b/kubernetes/deployment.yaml index 788f17c..cb675c1 100644 --- a/kubernetes/deployment.yaml +++ b/kubernetes/deployment.yaml @@ -16,7 +16,7 @@ spec: spec: containers: - name: currency-converter - image: nutaro/currency_converter:1.0.1 + image: nutaro/currency_converter:1.0.2 ports: - containerPort: 80 envFrom: From c95b0ac82eda6167693c37af29f5692e9f7bf730 Mon Sep 17 00:00:00 2001 From: nutaro Date: Thu, 31 Jul 2025 22:18:33 -0300 Subject: [PATCH 23/23] minor port fix --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 885c445..ed273a2 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ kubectl apply -f kubernetes/ ``` port forward the database ```shell -kubectl port-forward service/postgres-service 9000:8000 +kubectl port-forward service/postgres-service 8000:9000 ``` change your ENV VAR DATABASE_URL ```shell