From bbae5e21b47721227df45e60d92777d2f12056fb Mon Sep 17 00:00:00 2001 From: rgaudin Date: Fri, 15 Nov 2024 09:19:09 +0000 Subject: [PATCH] Initial Donation API This adds a couple of Stripe endpoints that are to be used by apple readers to create PaymentIntent for donations using Apple Pay --- .github/workflows/donation-api_cd.yaml | 27 +++ donation-api/.gitignore | 212 +++++++++++++++++++ donation-api/Dockerfile | 28 +++ donation-api/README.md | 1 + donation-api/pyproject.toml | 223 ++++++++++++++++++++ donation-api/src/donation_api/__about__.py | 3 + donation-api/src/donation_api/__init__.py | 0 donation-api/src/donation_api/constants.py | 37 ++++ donation-api/src/donation_api/entrypoint.py | 62 ++++++ donation-api/src/donation_api/stripe.py | 212 +++++++++++++++++++ donation-api/tasks.py | 110 ++++++++++ donation-api/tests/conftest.py | 19 ++ donation-api/tests/test_api.py | 8 + donation-api/tests/test_stripe.py | 15 ++ 14 files changed, 957 insertions(+) create mode 100644 .github/workflows/donation-api_cd.yaml create mode 100644 donation-api/.gitignore create mode 100644 donation-api/Dockerfile create mode 100644 donation-api/README.md create mode 100644 donation-api/pyproject.toml create mode 100644 donation-api/src/donation_api/__about__.py create mode 100644 donation-api/src/donation_api/__init__.py create mode 100644 donation-api/src/donation_api/constants.py create mode 100644 donation-api/src/donation_api/entrypoint.py create mode 100644 donation-api/src/donation_api/stripe.py create mode 100644 donation-api/tasks.py create mode 100644 donation-api/tests/conftest.py create mode 100644 donation-api/tests/test_api.py create mode 100644 donation-api/tests/test_stripe.py diff --git a/.github/workflows/donation-api_cd.yaml b/.github/workflows/donation-api_cd.yaml new file mode 100644 index 00000000..9292aec7 --- /dev/null +++ b/.github/workflows/donation-api_cd.yaml @@ -0,0 +1,27 @@ +name: Donation API + +on: + push: + branches: + - 'main' + paths: + - 'donation-api/**' + +jobs: + + donation-api: + name: Deploy Donation API Image + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v3.4.0 + - name: Publish Donation API Docker Image + uses: openzim/docker-publish-action@v10 + with: + image-name: kiwix/donation-api + on-master: latest + restrict-to: kiwix/container-images + context: donation-api + registries: ghcr.io + credentials: + GHCRIO_USERNAME=${{ secrets.GHCR_USERNAME }} + GHCRIO_TOKEN=${{ secrets.GHCR_TOKEN }} diff --git a/donation-api/.gitignore b/donation-api/.gitignore new file mode 100644 index 00000000..1b803a5a --- /dev/null +++ b/donation-api/.gitignore @@ -0,0 +1,212 @@ +# Created by https://www.toptal.com/developers/gitignore/api/python,macos +# Edit at https://www.toptal.com/developers/gitignore?templates=python,macos + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + +# End of https://www.toptal.com/developers/gitignore/api/python,macos + +# ignore all vscode, this is not standard configuration in this place +.vscode diff --git a/donation-api/Dockerfile b/donation-api/Dockerfile new file mode 100644 index 00000000..33cb4ba9 --- /dev/null +++ b/donation-api/Dockerfile @@ -0,0 +1,28 @@ +# syntax=docker/dockerfile:1 +# check=skip=SecretsUsedInArgOrEnv + +FROM python:3.12-alpine +LABEL org.opencontainers.image.source=https://github.com/kiwix/container-images + +# Copy pyproject.toml and its dependencies +COPY pyproject.toml README.md /src/ +COPY src/donation_api/__about__.py /src/src/donation_api/__about__.py + +# Install Python dependencies +RUN pip install --no-cache-dir /src + +COPY src /src/src +COPY *.md /src/ + +# Install + cleanup +RUN pip install --no-cache-dir /src \ + && rm -rf /src \ + && pip install --no-cache-dir uvicorn[standard]==0.32.0 + +# set STRIPE_USE_LIVE=1 for production (use of live key) +ENV STRIPE_USE_LIVE=0 +ENV STRIPE_TEST_KEY=notset +ENV STRIPE_LIVE_KEY=notset +ENV STRIPE_WEBHOOK_SECRET="" + +CMD ["uvicorn", "donation_api.entrypoint:app", "--host", "0.0.0.0", "--port", "80"] diff --git a/donation-api/README.md b/donation-api/README.md new file mode 100644 index 00000000..ece1ac25 --- /dev/null +++ b/donation-api/README.md @@ -0,0 +1 @@ +# donation-api diff --git a/donation-api/pyproject.toml b/donation-api/pyproject.toml new file mode 100644 index 00000000..917577fd --- /dev/null +++ b/donation-api/pyproject.toml @@ -0,0 +1,223 @@ +[build-system] +requires = ["hatchling", "hatch-openzim"] +build-backend = "hatchling.build" + +[project] +name = "donation-api" +requires-python = ">=3.12,<3.13" +description = "A simple Stripe relay endpoint" +readme = "README.md" +dependencies = [ + "stripe==11.2.0", + "fastapi[standard]==0.115.5" +] +dynamic = ["authors", "classifiers", "keywords", "license", "version", "urls"] + + +[project.optional-dependencies] +scripts = [ + "invoke==2.2.0", +] +lint = [ + "black==24.10.0", + "ruff==0.7.3", +] +check = [ + "pyright==1.1.389", + "donation-api[test]", +] +test = [ + "pytest==8.3.3", + "httpx==0.27.2", + "coverage==7.6.4", +] +dev = [ + "pre-commit==4.0.1", + "ipython==8.29.0", + "donation-api[scripts]", + "donation-api[lint]", + "donation-api[test]", + "donation-api[check]", +] + +[project.scripts] +run = "donation_api:entrypoint" + +[tool.hatch.version] +path = "src/donation_api/__about__.py" + +[tool.hatch.build] +exclude = [ + "/.github", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/donation_api"] + +[tool.hatch.envs.default] +features = ["dev"] + +[tool.hatch.envs.test] +features = ["scripts", "test"] + +[[tool.hatch.envs.test.matrix]] +python = ["3.12"] + +[tool.hatch.envs.test.scripts] +run = "inv test --args '{args}'" +run-cov = "inv test-cov --args '{args}'" +report-cov = "inv report-cov" +coverage = "inv coverage --args '{args}'" +html = "inv coverage --html --args '{args}'" + +[tool.hatch.envs.lint] +template = "lint" +skip-install = false +features = ["scripts", "lint"] + +[tool.hatch.envs.lint.scripts] +black = "inv lint-black --args '{args}'" +ruff = "inv lint-ruff --args '{args}'" +all = "inv lintall --args '{args}'" +fix-black = "inv fix-black --args '{args}'" +fix-ruff = "inv fix-ruff --args '{args}'" +fixall = "inv fixall --args '{args}'" + +[tool.hatch.envs.check] +features = ["scripts", "check"] + +[tool.hatch.envs.check.scripts] +pyright = "inv check-pyright --args '{args}'" +all = "inv checkall --args '{args}'" + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.ruff] +target-version = "py312" +line-length = 88 +src = ["src"] + +[tool.ruff.lint] +select = [ + "A", # flake8-builtins + # "ANN", # flake8-annotations + "ARG", # flake8-unused-arguments + # "ASYNC", # flake8-async + "B", # flake8-bugbear + # "BLE", # flake8-blind-except + "C4", # flake8-comprehensions + "C90", # mccabe + # "COM", # flake8-commas + # "D", # pydocstyle + # "DJ", # flake8-django + "DTZ", # flake8-datetimez + "E", # pycodestyle (default) + "EM", # flake8-errmsg + # "ERA", # eradicate + # "EXE", # flake8-executable + "F", # Pyflakes (default) + # "FA", # flake8-future-annotations + "FBT", # flake8-boolean-trap + # "FLY", # flynt + # "G", # flake8-logging-format + "I", # isort + "ICN", # flake8-import-conventions + # "INP", # flake8-no-pep420 + # "INT", # flake8-gettext + "ISC", # flake8-implicit-str-concat + "N", # pep8-naming + # "NPY", # NumPy-specific rules + # "PD", # pandas-vet + # "PGH", # pygrep-hooks + # "PIE", # flake8-pie + # "PL", # Pylint + "PLC", # Pylint: Convention + "PLE", # Pylint: Error + "PLR", # Pylint: Refactor + "PLW", # Pylint: Warning + # "PT", # flake8-pytest-style + # "PTH", # flake8-use-pathlib + # "PYI", # flake8-pyi + "Q", # flake8-quotes + # "RET", # flake8-return + # "RSE", # flake8-raise + "RUF", # Ruff-specific rules + "S", # flake8-bandit + # "SIM", # flake8-simplify + # "SLF", # flake8-self + "T10", # flake8-debugger + "T20", # flake8-print + # "TCH", # flake8-type-checking + # "TD", # flake8-todos + "TID", # flake8-tidy-imports + # "TRY", # tryceratops + "UP", # pyupgrade + "W", # pycodestyle + "YTT", # flake8-2020 +] +ignore = [ + # Allow non-abstract empty methods in abstract base classes + "B027", + # Remove flake8-errmsg since we consider they bloat the code and provide limited value + "EM", + # Allow boolean positional values in function calls, like `dict.get(... True)` + "FBT003", + # Ignore checks for possible passwords + "S105", "S106", "S107", + # Ignore warnings on subprocess.run / popen + "S603", + # Ignore complexity + "C901", "PLR0911", "PLR0912", "PLR0913", "PLR0915", +] +unfixable = [ + # Don't touch unused imports + "F401", +] + +[tool.ruff.lint.isort] +known-first-party = ["donation_api"] + +[tool.ruff.lint.flake8-bugbear] +# add exceptions to B008 for fastapi. +extend-immutable-calls = ["fastapi.Depends", "fastapi.Query"] + +[tool.ruff.lint.flake8-tidy-imports] +ban-relative-imports = "all" + +[tool.ruff.lint.per-file-ignores] +# Tests can use magic values, assertions, and relative imports +"tests/**/*" = ["PLR2004", "S101", "TID252"] + +[tool.pytest.ini_options] +minversion = "7.3" +testpaths = ["tests"] +pythonpath = [".", "src"] + +[tool.coverage.paths] +donation_api = ["src/donation_api"] +tests = ["tests"] + +[tool.coverage.run] +source_pkgs = ["donation_api"] +branch = true +parallel = true +omit = [ + "src/donation_api/__about__.py", +] + +[tool.coverage.report] +exclude_lines = [ + "no cov", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] + +[tool.pyright] +include = ["src", "tests", "tasks.py"] +exclude = [".env/**", ".venv/**"] +extraPaths = ["src"] +pythonVersion = "3.12" +typeCheckingMode="strict" +disableBytesTypePromotions = true diff --git a/donation-api/src/donation_api/__about__.py b/donation-api/src/donation_api/__about__.py new file mode 100644 index 00000000..fc71f653 --- /dev/null +++ b/donation-api/src/donation_api/__about__.py @@ -0,0 +1,3 @@ +__title__ = "Kiwix Donation API" +__description__ = "A simple Donation API for reader apps" +__version__ = "1.0.0" diff --git a/donation-api/src/donation_api/__init__.py b/donation-api/src/donation_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/donation-api/src/donation_api/constants.py b/donation-api/src/donation_api/constants.py new file mode 100644 index 00000000..20fc6f17 --- /dev/null +++ b/donation-api/src/donation_api/constants.py @@ -0,0 +1,37 @@ +import os +from dataclasses import dataclass, field + +import requests + + +@dataclass +class Constants: + stripe_on_prod: bool = bool(os.getenv("STRIPE_USE_LIVE") == "1") + stripe_test_key: str = os.getenv("STRIPE_TEST_KEY") or "notset" + stripe_live_key: str = os.getenv("STRIPE_LIVE_KEY") or "notset" + stripe_webhook_secret: str = os.getenv("STRIPE_WEBHOOK_SECRET") or "" + stripe_webhook_sender_ips: list[str] = field(default_factory=list) + stripe_webhook_testing_ips: list[str] = field(default_factory=list) + + stripe_minimal_amount: float = 1.0 + stripe_maximum_amount: float = 1000000 + + def __post_init__(self): + self.stripe_webhook_testing_ips = os.getenv( + "STRIPE_WEBHOOK_TESTING_IPS", "" + ).split("|") + + resp = requests.get("https://stripe.com/files/ips/ips_webhooks.txt", timeout=5) + resp.raise_for_status() + self.stripe_webhook_sender_ips = resp.text.strip().split("\n") + if not self.stripe_webhook_sender_ips: + raise OSError("No Stripe Webhook IPs!") + + @property + def stripe_api_key(self) -> str: + if self.stripe_on_prod: + return self.stripe_live_key + return self.stripe_test_key + + +conf = Constants() diff --git a/donation-api/src/donation_api/entrypoint.py b/donation-api/src/donation_api/entrypoint.py new file mode 100644 index 00000000..846fee8c --- /dev/null +++ b/donation-api/src/donation_api/entrypoint.py @@ -0,0 +1,62 @@ +from http import HTTPStatus + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import RedirectResponse + +from donation_api import stripe +from donation_api.__about__ import __description__, __title__, __version__ + +PREFIX = "/v1" + + +def create_app() -> FastAPI: + app = FastAPI( + title=__title__, + description=__description__, + version=__version__, + ) + + @app.get("/") + async def _(): + """Redirect to root of latest version of the API""" + return RedirectResponse(f"{PREFIX}/", status_code=HTTPStatus.PERMANENT_REDIRECT) + + api = FastAPI( + title=__title__, + description=__description__, + version=__version__, + docs_url="/", + openapi_tags=[ + { + "name": "stripe", + "description": "Stripe relay", + } + ], + contact={ + "name": "Kiwix", + "url": "https://www.kiwix.org/en/contact/", + "email": "contact+donation@kiwix.org", + }, + license_info={ + "name": "GNU General Public License v3.0", + "url": "https://www.gnu.org/licenses/gpl-3.0.en.html", + }, + ) + + api.add_middleware( + CORSMiddleware, + allow_origins=["*"], # API meant to be called by clients everywhere + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + api.include_router(router=stripe.router) + + app.mount(PREFIX, api) + + return app + + +app = create_app() diff --git a/donation-api/src/donation_api/stripe.py b/donation-api/src/donation_api/stripe.py new file mode 100644 index 00000000..44b09e18 --- /dev/null +++ b/donation-api/src/donation_api/stripe.py @@ -0,0 +1,212 @@ +import logging +import re +from http import HTTPStatus +from typing import Annotated, Any + +import stripe +from fastapi import APIRouter, Depends, Header, HTTPException, Request +from pydantic import BaseModel, ConfigDict +from stripe import Event, StripeError, Webhook + +from donation_api.constants import conf + +logger = logging.getLogger("uvicorn.error") +stripe.api_key = conf.stripe_api_key + +router = APIRouter( + prefix="/stripe", + tags=["stripe"], +) + + +class PaymentIntentRequest(BaseModel): + """Request Payload for a PaymentIntent creation""" + + amount: int + currency: str + + +class PaymentIntent(BaseModel): + """Our response to PaymentIntent request""" + + secret: str + + +class StripeWebhookPayload(BaseModel): + """Stripe-sent payload during the webhook call + https://stripe.com/docs/webhooks""" + + model_config = ConfigDict(extra="allow") + + id: str + object: str + api_version: str + created: int + data: dict[str, Any] # at this point that's enough + livemode: bool + pending_webhooks: int + request: dict[str, Any] + type: str + + +class StripeWebhookResponse(BaseModel): + """Response to Stripe from the Webhook so Stripe is able to record whether + processing went fine or not""" + + status: str + + +async def get_body(request: Request): + """raw request body""" + return await request.body() + + +def can_send_webhook(ip_addr: str) -> bool: + """whether an IP is allowed to submit webhook requests""" + if not conf.stripe_on_prod: + return ip_addr in [ + *conf.stripe_webhook_sender_ips, + *conf.stripe_webhook_testing_ips, + "127.0.0.1", + ] + return ip_addr in conf.stripe_webhook_sender_ips + + +@router.get( + "/health-check", + status_code=HTTPStatus.OK, + responses={ + HTTPStatus.INTERNAL_SERVER_ERROR: { + "description": "Health check failed", + }, + HTTPStatus.OK: { + "model": str, + "description": "Health Check passed", + }, + }, +) +async def check_config(): + errors: list[str] = [] + if conf.stripe_on_prod and not str(stripe.api_key).startswith("sk_live_"): + errors.append("Missing Live API Key") + + if not conf.stripe_on_prod and not str(stripe.api_key).startswith("sk_test_"): + errors.append("Missing Test API Key") + + if not conf.stripe_webhook_sender_ips: + errors.append("Missing Stripe IPs") + + if errors: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="\n".join(errors) + ) + return "OK" + + +@router.post( + "/payment-intent", + responses={ + HTTPStatus.BAD_REQUEST: { + "description": "PaymentIntent request was not understood", + }, + HTTPStatus.CREATED: { + "model": PaymentIntent, + "description": "Stripe-created PaymentIntent", + }, + }, + status_code=HTTPStatus.CREATED, +) +async def create_payment_intent(pi_payload: PaymentIntentRequest): + """API endpoint to receive Book addition requests and add to database""" + if not re.match(r"[a-z]{3}", pi_payload.currency.lower()): + logger.error("Currency doesnt look like a currency") + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Currency doesnt look like a currency", + ) + + if ( + pi_payload.amount < conf.stripe_minimal_amount + or pi_payload.amount > conf.stripe_maximum_amount + ): + logger.error("Amount not within range") + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Amount not within range", + ) + logger.info(f"PI for {pi_payload.amount} {pi_payload.currency}") + try: + intent = stripe.PaymentIntent.create( + amount=pi_payload.amount, + currency=pi_payload.currency.lower(), + use_stripe_sdk=True, + ) + return {"secret": intent.client_secret}, HTTPStatus.CREATED + except StripeError as exc: + logger.error(repr(exc)) + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail=str(exc) + ) from exc + except Exception as exc: + logger.error(repr(exc)) + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail=str(exc) + ) from exc + + +@router.post( + "/webhook", + responses={ + HTTPStatus.BAD_REQUEST: { + "description": "Webhook request was not understood", + }, + HTTPStatus.OK: { + "model": StripeWebhookResponse, + "description": "Webhook processing went fine", + }, + }, + status_code=HTTPStatus.OK, +) +def webhook_received( + webhook_payload: StripeWebhookPayload, + request: Request, + body: bytes = Depends(get_body), + stripe_signature: Annotated[str | None, Header()] = None, +): + client_host = request.client.host if request.client else "" + if not can_send_webhook(client_host): + logger.error(f"Not from a Strip Webhook IP: {client_host}") + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, detail="Not from a Strip Webhook IP" + ) + # retrieve the event by verifying the signature using the raw body + # and secret if webhook signing is configured. + if conf.stripe_webhook_secret and stripe_signature: + try: + event: Event = ( + Webhook.construct_event( # pyright: ignore [ reportUnknownMemberType] + payload=body.decode("UTF-8"), + sig_header=stripe_signature, + secret=conf.stripe_webhook_secret, + ) + ) + data = event["data"] + except Exception as exc: + logger.error(exc) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=f"Event construct failed: {exc!r}", + ) from exc + event_type = event["type"] + else: + data = webhook_payload.data + event_type = webhook_payload.type + data_object = data["object"] + + if event_type == "payment_intent.succeeded": + logger.info("💰 Payment received!") + logger.debug(data_object) + elif event_type == "payment_intent.payment_failed": + logger.info("❌ Payment failed.") + + return {"status": "success"} diff --git a/donation-api/tasks.py b/donation-api/tasks.py new file mode 100644 index 00000000..87cd5529 --- /dev/null +++ b/donation-api/tasks.py @@ -0,0 +1,110 @@ +# pyright: strict, reportUntypedFunctionDecorator=false +import os + +from invoke.context import Context +from invoke.tasks import task # pyright: ignore [reportUnknownVariableType] + +use_pty = not os.getenv("CI", "") + + +@task(optional=["args"], help={"args": "pytest additional arguments"}) +def test(ctx: Context, args: str = ""): + """run tests (without coverage)""" + ctx.run(f"pytest {args}", pty=use_pty) + + +@task(optional=["args"], help={"args": "pytest additional arguments"}) +def test_cov(ctx: Context, args: str = ""): + """run test vith coverage""" + ctx.run(f"coverage run -m pytest {args}", pty=use_pty) + + +@task(optional=["html"], help={"html": "flag to export html report"}) +def report_cov(ctx: Context, *, html: bool = False): + """report coverage""" + ctx.run("coverage combine", warn=True, pty=use_pty) + ctx.run("coverage report --show-missing", pty=use_pty) + ctx.run("coverage xml", pty=use_pty) + if html: + ctx.run("coverage html", pty=use_pty) + + +@task( + optional=["args", "html"], + help={ + "args": "pytest additional arguments", + "html": "flag to export html report", + }, +) +def coverage(ctx: Context, args: str = "", *, html: bool = False): + """run tests and report coverage""" + test_cov(ctx, args=args) + report_cov(ctx, html=html) + + +@task(optional=["args"], help={"args": "black additional arguments"}) +def lint_black(ctx: Context, args: str = "."): + args = args or "." # needed for hatch script + ctx.run("black --version", pty=use_pty) + ctx.run(f"black --check --diff {args}", pty=use_pty) + + +@task(optional=["args"], help={"args": "ruff additional arguments"}) +def lint_ruff(ctx: Context, args: str = "."): + args = args or "." # needed for hatch script + ctx.run("ruff --version", pty=use_pty) + ctx.run(f"ruff check {args}", pty=use_pty) + + +@task( + optional=["args"], + help={ + "args": "linting tools (black, ruff) additional arguments, typically a path", + }, +) +def lintall(ctx: Context, args: str = "."): + """Check linting""" + args = args or "." # needed for hatch script + lint_black(ctx, args) + lint_ruff(ctx, args) + + +@task(optional=["args"], help={"args": "check tools (pyright) additional arguments"}) +def check_pyright(ctx: Context, args: str = ""): + """check static types with pyright""" + ctx.run("pyright --version") + ctx.run(f"pyright {args}", pty=use_pty) + + +@task(optional=["args"], help={"args": "check tools (pyright) additional arguments"}) +def checkall(ctx: Context, args: str = ""): + """check static types""" + check_pyright(ctx, args) + + +@task(optional=["args"], help={"args": "black additional arguments"}) +def fix_black(ctx: Context, args: str = "."): + """fix black formatting""" + args = args or "." # needed for hatch script + ctx.run(f"black {args}", pty=use_pty) + + +@task(optional=["args"], help={"args": "ruff additional arguments"}) +def fix_ruff(ctx: Context, args: str = "."): + """fix all ruff rules""" + args = args or "." # needed for hatch script + ctx.run(f"ruff check --fix {args}", pty=use_pty) + + +@task( + optional=["args"], + help={ + "args": "linting tools (black, ruff) additional arguments, typically a path", + }, +) +def fixall(ctx: Context, args: str = "."): + """Fix everything automatically""" + args = args or "." # needed for hatch script + fix_black(ctx, args) + fix_ruff(ctx, args) + lintall(ctx, args) diff --git a/donation-api/tests/conftest.py b/donation-api/tests/conftest.py new file mode 100644 index 00000000..d152edaf --- /dev/null +++ b/donation-api/tests/conftest.py @@ -0,0 +1,19 @@ +import pytest +from fastapi.testclient import TestClient + +from donation_api.entrypoint import app + + +@pytest.fixture(scope="session") +def client(): + yield TestClient( + app, + base_url="http://testserver", + raise_server_exceptions=True, + root_path="", + backend="asyncio", + backend_options=None, + cookies=None, + headers=None, + follow_redirects=True, + ) diff --git a/donation-api/tests/test_api.py b/donation-api/tests/test_api.py new file mode 100644 index 00000000..c651a1fc --- /dev/null +++ b/donation-api/tests/test_api.py @@ -0,0 +1,8 @@ +from http import HTTPStatus + +from fastapi.testclient import TestClient + + +def test_root(client: TestClient): + resp = client.get("/", follow_redirects=False) + assert resp.status_code == HTTPStatus.PERMANENT_REDIRECT diff --git a/donation-api/tests/test_stripe.py b/donation-api/tests/test_stripe.py new file mode 100644 index 00000000..63d0f204 --- /dev/null +++ b/donation-api/tests/test_stripe.py @@ -0,0 +1,15 @@ +from http import HTTPStatus + +from fastapi.testclient import TestClient + +from donation_api.constants import conf + + +def test_default_to_test(): + assert not conf.stripe_on_prod + + +def test_check_config(client: TestClient): + resp = client.get("/v1/stripe/health-check") + assert resp.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + assert resp.json().get("detail") == "Missing Test API Key"