cashing2fast-fastapi 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cashing2fast_fastapi-0.1.0/LICENSE +21 -0
- cashing2fast_fastapi-0.1.0/MANIFEST.in +4 -0
- cashing2fast_fastapi-0.1.0/PKG-INFO +65 -0
- cashing2fast_fastapi-0.1.0/README.md +19 -0
- cashing2fast_fastapi-0.1.0/docs/usage.md +76 -0
- cashing2fast_fastapi-0.1.0/examples/.env.examples +18 -0
- cashing2fast_fastapi-0.1.0/pyproject.toml +71 -0
- cashing2fast_fastapi-0.1.0/setup.cfg +4 -0
- cashing2fast_fastapi-0.1.0/src/cashing2fast_fastapi/__init__.py +12 -0
- cashing2fast_fastapi-0.1.0/src/cashing2fast_fastapi/__version__.py +1 -0
- cashing2fast_fastapi-0.1.0/src/cashing2fast_fastapi/dependencies.py +91 -0
- cashing2fast_fastapi-0.1.0/src/cashing2fast_fastapi/exceptions.py +6 -0
- cashing2fast_fastapi-0.1.0/src/cashing2fast_fastapi/services/__init__.py +7 -0
- cashing2fast_fastapi-0.1.0/src/cashing2fast_fastapi/services/billing_service.py +62 -0
- cashing2fast_fastapi-0.1.0/src/cashing2fast_fastapi/settings.py +68 -0
- cashing2fast_fastapi-0.1.0/src/cashing2fast_fastapi/utils/__init__.py +6 -0
- cashing2fast_fastapi-0.1.0/src/cashing2fast_fastapi/utils/redis_client.py +25 -0
- cashing2fast_fastapi-0.1.0/src/cashing2fast_fastapi.egg-info/PKG-INFO +65 -0
- cashing2fast_fastapi-0.1.0/src/cashing2fast_fastapi.egg-info/SOURCES.txt +21 -0
- cashing2fast_fastapi-0.1.0/src/cashing2fast_fastapi.egg-info/dependency_links.txt +1 -0
- cashing2fast_fastapi-0.1.0/src/cashing2fast_fastapi.egg-info/requires.txt +4 -0
- cashing2fast_fastapi-0.1.0/src/cashing2fast_fastapi.egg-info/top_level.txt +1 -0
- cashing2fast_fastapi-0.1.0/tests/test_billing.py +104 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Angel Daniel Sanchez Castillo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cashing2fast-fastapi
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Billing and request limit control for FastAPI
|
|
5
|
+
Author-email: Angel Daniel Sanchez Castillo <angeldaniel.sanchezcastillo@gmail.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Angel Daniel Sanchez Castillo
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Keywords: fastapi,billing,request-limit,redis,oauth2
|
|
29
|
+
Classifier: Development Status :: 3 - Alpha
|
|
30
|
+
Classifier: Intended Audience :: Developers
|
|
31
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
32
|
+
Classifier: Programming Language :: Python :: 3
|
|
33
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
34
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
36
|
+
Classifier: Framework :: FastAPI
|
|
37
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
38
|
+
Requires-Python: >=3.10
|
|
39
|
+
Description-Content-Type: text/markdown
|
|
40
|
+
License-File: LICENSE
|
|
41
|
+
Requires-Dist: oauth2fast-fastapi>=0.3.0
|
|
42
|
+
Requires-Dist: tools2fast-fastapi>=0.1.5
|
|
43
|
+
Requires-Dist: redis>=5.0.0
|
|
44
|
+
Requires-Dist: pydantic-settings>=2.0.0
|
|
45
|
+
Dynamic: license-file
|
|
46
|
+
|
|
47
|
+
# cashing2fast-fastapi
|
|
48
|
+
🚀 Simple and fast cashing tools for FastAPI with minimal configuration
|
|
49
|
+
|
|
50
|
+
## Documentación
|
|
51
|
+
|
|
52
|
+
- [Guía de Uso (Usage Guide)](docs/usage.md)
|
|
53
|
+
- [Ejemplos de configuración](examples/.env.examples)
|
|
54
|
+
|
|
55
|
+
## Instalación
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
uv add cashing2fast-fastapi
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Características
|
|
62
|
+
|
|
63
|
+
- 🎯 **Lógica de 3 Fases:** Tiempo gratuito, periodo de cobro por peticiones y bloqueo final.
|
|
64
|
+
- ⚡ **Redis Native:** Contadores atómicos y caché de usuario para evitar cargas innecesarias a DB.
|
|
65
|
+
- 🛡️ **Tools2Fast Integration:** Respuestas con formato estándar y código HTTP 402.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# cashing2fast-fastapi
|
|
2
|
+
🚀 Simple and fast cashing tools for FastAPI with minimal configuration
|
|
3
|
+
|
|
4
|
+
## Documentación
|
|
5
|
+
|
|
6
|
+
- [Guía de Uso (Usage Guide)](docs/usage.md)
|
|
7
|
+
- [Ejemplos de configuración](examples/.env.examples)
|
|
8
|
+
|
|
9
|
+
## Instalación
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
uv add cashing2fast-fastapi
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Características
|
|
16
|
+
|
|
17
|
+
- 🎯 **Lógica de 3 Fases:** Tiempo gratuito, periodo de cobro por peticiones y bloqueo final.
|
|
18
|
+
- ⚡ **Redis Native:** Contadores atómicos y caché de usuario para evitar cargas innecesarias a DB.
|
|
19
|
+
- 🛡️ **Tools2Fast Integration:** Respuestas con formato estándar y código HTTP 402.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Guía de Uso: Cashing2Fast FastAPI
|
|
2
|
+
|
|
3
|
+
`cashing2fast-fastapi` es una extensión para gestionar límites de peticiones y control de facturación en aplicaciones FastAPI, utilizando Redis para alta velocidad y minimizando consultas a la base de datos.
|
|
4
|
+
|
|
5
|
+
## 1. Configuración de Entorno (.env)
|
|
6
|
+
|
|
7
|
+
Asegúrate de tener las siguientes variables en tu archivo `.env`. Puedes ver un ejemplo completo en [examples/.env.examples](../examples/.env.examples).
|
|
8
|
+
|
|
9
|
+
```env
|
|
10
|
+
# Límites de Facturación (en minutos)
|
|
11
|
+
CASHING_FREE_MINUTES=5 # Minutos gratis después de crear la cuenta
|
|
12
|
+
CASHING_REDIRECT_MINUTES=60 # Minutos en fase de cobro/conteo
|
|
13
|
+
CASHING_MAX_REQUESTS=10 # Máximo de peticiones en fase de cobro
|
|
14
|
+
|
|
15
|
+
# Redis para el contador y caché
|
|
16
|
+
CASHING_REDIS__HOST=localhost
|
|
17
|
+
CASHING_REDIS__PORT=6379
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## 2. Registro del Manejador de Excepciones
|
|
21
|
+
|
|
22
|
+
Para que tu API responda con el formato estándar (402 Payment Required) y el esquema de `tools2fast`, debes registrar el manejador de excepciones en tu archivo `main.py`:
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from fastapi import FastAPI
|
|
26
|
+
from cashing2fast_fastapi.dependencies import register_billing_exception_handler
|
|
27
|
+
|
|
28
|
+
app = FastAPI()
|
|
29
|
+
|
|
30
|
+
# Esto permite que la excepción PaymentRequiredException
|
|
31
|
+
# se convierta automáticamente en una respuesta Tools2Fast
|
|
32
|
+
register_billing_exception_handler(app)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## 3. Uso de la Dependencia en Routers
|
|
36
|
+
|
|
37
|
+
Para proteger un endpoint o un router completo, simplemente utiliza la dependencia `require_billing_checks`.
|
|
38
|
+
|
|
39
|
+
### Proteger un Endpoint Individual:
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from fastapi import APIRouter, Depends
|
|
43
|
+
from cashing2fast_fastapi import require_billing_checks
|
|
44
|
+
|
|
45
|
+
router = APIRouter()
|
|
46
|
+
|
|
47
|
+
@router.get("/data-limitada", dependencies=[Depends(require_billing_checks)])
|
|
48
|
+
async def get_protected_data():
|
|
49
|
+
return {"message": "Has pasado el filtro de facturación correctamente"}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Proteger un Router Completo:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
router = APIRouter(
|
|
56
|
+
prefix="/api/premium",
|
|
57
|
+
dependencies=[Depends(require_billing_checks)]
|
|
58
|
+
)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## 4. ¿Cómo funciona internamente?
|
|
62
|
+
|
|
63
|
+
La dependencia sigue una lógica de **3 fases** basada en la fecha de creación del usuario (`user.created_at`):
|
|
64
|
+
|
|
65
|
+
1. **Fase Gratuita:** Si el tiempo desde la creación es menor o igual a `CASHING_FREE_MINUTES`, el usuario puede navegar libremente.
|
|
66
|
+
2. **Fase de Conteo:** Si el tiempo es mayor a los minutos gratis pero menor al tiempo de redirección (`FREE + REDIRECT`):
|
|
67
|
+
* Se cuenta cada petición en Redis.
|
|
68
|
+
* Si se supera el `CASHING_MAX_REQUESTS`, se lanza un error **402**.
|
|
69
|
+
* **Importante:** Al lanzar el error, el contador se resetea a 0 para que el usuario pueda volver a navegar tras ser redireccionado por el frontend.
|
|
70
|
+
3. **Fase Bloqueada:** Si el tiempo total expira, el acceso se bloquea permanentemente con un error **402** hasta que se realice un pago.
|
|
71
|
+
|
|
72
|
+
## 5. Optimización (Redis)
|
|
73
|
+
|
|
74
|
+
La librería minimiza el uso de la base de datos:
|
|
75
|
+
* La primera vez que un usuario hace una petición, se busca su `id` y `created_at` en la DB y se guarda en Redis.
|
|
76
|
+
* En todas las peticiones siguientes, la información se extrae directamente de Redis usando el token Bearer, ahorrando una consulta SQL por cada request.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Cashing2Fast FastAPI configuration example
|
|
2
|
+
SECRET_KEY=yoursecretkeyhere
|
|
3
|
+
|
|
4
|
+
# Database connections (Standard for your apps)
|
|
5
|
+
DB_CONNECTIONS__AUTH__HOST=localhost
|
|
6
|
+
DB_CONNECTIONS__AUTH__PORT=5432
|
|
7
|
+
DB_CONNECTIONS__AUTH__USERNAME=postgres
|
|
8
|
+
DB_CONNECTIONS__AUTH__PASSWORD=postgres
|
|
9
|
+
DB_CONNECTIONS__AUTH__DATABASE=postgres
|
|
10
|
+
|
|
11
|
+
# Billing Logic
|
|
12
|
+
CASHING_FREE_MINUTES=0
|
|
13
|
+
CASHING_REDIRECT_MINUTES=60
|
|
14
|
+
CASHING_MAX_REQUESTS=100
|
|
15
|
+
|
|
16
|
+
# Redis Connection
|
|
17
|
+
CASHING_REDIS__HOST=localhost
|
|
18
|
+
CASHING_REDIS__PORT=6379
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "cashing2fast-fastapi"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Billing and request limit control for FastAPI"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = {file = "LICENSE"}
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "Angel Daniel Sanchez Castillo", email = "angeldaniel.sanchezcastillo@gmail.com"}
|
|
14
|
+
]
|
|
15
|
+
keywords = ["fastapi", "billing", "request-limit", "redis", "oauth2"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Framework :: FastAPI",
|
|
25
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
dependencies = [
|
|
29
|
+
"oauth2fast-fastapi>=0.3.0",
|
|
30
|
+
"tools2fast-fastapi>=0.1.5",
|
|
31
|
+
"redis>=5.0.0",
|
|
32
|
+
"pydantic-settings>=2.0.0",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[dependency-groups]
|
|
36
|
+
dev = [
|
|
37
|
+
"black",
|
|
38
|
+
"ruff",
|
|
39
|
+
"mypy>=1.0.0",
|
|
40
|
+
"pre-commit",
|
|
41
|
+
"build",
|
|
42
|
+
"twine",
|
|
43
|
+
]
|
|
44
|
+
test = [
|
|
45
|
+
"pytest>=7.0.0",
|
|
46
|
+
"pytest-asyncio>=0.21.0",
|
|
47
|
+
"pytest-cov>=4.0.0",
|
|
48
|
+
"httpx>=0.24.0",
|
|
49
|
+
"python-dotenv>=1.0.0",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
[tool.setuptools]
|
|
53
|
+
package-dir = {"" = "src"}
|
|
54
|
+
|
|
55
|
+
[tool.setuptools.packages.find]
|
|
56
|
+
where = ["src"]
|
|
57
|
+
include = ["cashing2fast_fastapi*"]
|
|
58
|
+
|
|
59
|
+
[tool.pytest.ini_options]
|
|
60
|
+
testpaths = ["tests"]
|
|
61
|
+
python_files = ["test_*.py"]
|
|
62
|
+
python_classes = ["Test*"]
|
|
63
|
+
python_functions = ["test_*"]
|
|
64
|
+
asyncio_mode = "auto"
|
|
65
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
66
|
+
|
|
67
|
+
[tool.mypy]
|
|
68
|
+
python_version = "3.10"
|
|
69
|
+
warn_return_any = true
|
|
70
|
+
warn_unused_configs = true
|
|
71
|
+
disallow_untyped_defs = false
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from .__version__ import __version__
|
|
2
|
+
from .settings import settings
|
|
3
|
+
from .dependencies import require_billing_checks
|
|
4
|
+
from .utils.redis_client import get_redis_client, close_redis
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"__version__",
|
|
8
|
+
"settings",
|
|
9
|
+
"require_billing_checks",
|
|
10
|
+
"get_redis_client",
|
|
11
|
+
"close_redis",
|
|
12
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
from fastapi import Depends, HTTPException, status, Request
|
|
4
|
+
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
5
|
+
from oauth2fast_fastapi.dependencies import get_auth_session, oauth2_dependency
|
|
6
|
+
from oauth2fast_fastapi.utils.token_utils import verify_token
|
|
7
|
+
from tools2fast_fastapi import APIResponse
|
|
8
|
+
|
|
9
|
+
from .settings import settings
|
|
10
|
+
from .services import billing_service
|
|
11
|
+
from .exceptions import PaymentRequiredException
|
|
12
|
+
|
|
13
|
+
async def require_billing_checks(
|
|
14
|
+
request: Request,
|
|
15
|
+
token: str = Depends(oauth2_dependency),
|
|
16
|
+
session: AsyncSession = Depends(get_auth_session)
|
|
17
|
+
):
|
|
18
|
+
"""
|
|
19
|
+
Dependency to check if the user has billing limits.
|
|
20
|
+
"""
|
|
21
|
+
# 1. Decode token to get email without hitting User DB yet
|
|
22
|
+
payload = verify_token(token)
|
|
23
|
+
if not payload:
|
|
24
|
+
raise HTTPException(
|
|
25
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
26
|
+
detail="Token inválido",
|
|
27
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
email = payload.get("sub")
|
|
31
|
+
if not email:
|
|
32
|
+
raise HTTPException(
|
|
33
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
34
|
+
detail="Token sin identificador",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# 2. Get User ID and created_at (cached in Redis)
|
|
38
|
+
try:
|
|
39
|
+
user_info = await billing_service.get_user_billing_info(email, session)
|
|
40
|
+
except Exception:
|
|
41
|
+
raise HTTPException(
|
|
42
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
43
|
+
detail="Usuario no encontrado",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
user_id = user_info["id"]
|
|
47
|
+
created_at = datetime.fromisoformat(user_info["created_at"])
|
|
48
|
+
if created_at.tzinfo is None:
|
|
49
|
+
created_at = created_at.replace(tzinfo=timezone.utc)
|
|
50
|
+
|
|
51
|
+
# 3. Calculate elapsed minutes
|
|
52
|
+
now = datetime.now(timezone.utc)
|
|
53
|
+
elapsed_minutes = (now - created_at).total_seconds() / 60
|
|
54
|
+
|
|
55
|
+
# 4. Evaluate phases
|
|
56
|
+
|
|
57
|
+
# Phase 1: Unlimited (Free period)
|
|
58
|
+
if elapsed_minutes <= settings.free_minutes:
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
# Phase 2: Tracked (Redirect period)
|
|
62
|
+
if elapsed_minutes <= (settings.free_minutes + settings.redirect_minutes):
|
|
63
|
+
count = await billing_service.increment_request_count(user_id)
|
|
64
|
+
if count > settings.max_requests:
|
|
65
|
+
# Reseteo el valor en el redis para que la pueda seguir ocupando
|
|
66
|
+
# hasta llegar a ese número nuevamente después de la redirección
|
|
67
|
+
await billing_service.reset_request_count(user_id)
|
|
68
|
+
# Raise exception to stop execution and return 402
|
|
69
|
+
raise PaymentRequiredException(
|
|
70
|
+
message="Límite de peticiones alcanzado. Por favor, realiza un pago."
|
|
71
|
+
)
|
|
72
|
+
return True
|
|
73
|
+
|
|
74
|
+
# Phase 3: Blocked (Expired)
|
|
75
|
+
raise PaymentRequiredException(
|
|
76
|
+
message="Su periodo de uso ha expirado. Por favor, realice un pago para continuar."
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def register_billing_exception_handler(app: Any):
|
|
80
|
+
"""
|
|
81
|
+
Register the global exception handler for PaymentRequiredException.
|
|
82
|
+
"""
|
|
83
|
+
from fastapi import Request
|
|
84
|
+
from fastapi.responses import JSONResponse
|
|
85
|
+
|
|
86
|
+
@app.exception_handler(PaymentRequiredException)
|
|
87
|
+
async def billing_exception_handler(request: Request, exc: PaymentRequiredException):
|
|
88
|
+
return APIResponse.payment_required(
|
|
89
|
+
message=exc.message,
|
|
90
|
+
error=exc.error
|
|
91
|
+
)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from typing import TypedDict, Any
|
|
4
|
+
from sqlmodel import select
|
|
5
|
+
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
6
|
+
from oauth2fast_fastapi import User
|
|
7
|
+
from ..utils.redis_client import get_redis_client
|
|
8
|
+
from ..settings import settings
|
|
9
|
+
|
|
10
|
+
class UserAuthCache(TypedDict):
|
|
11
|
+
id: int
|
|
12
|
+
created_at: str
|
|
13
|
+
|
|
14
|
+
async def get_user_billing_info(email: str, session: AsyncSession) -> UserAuthCache:
|
|
15
|
+
"""
|
|
16
|
+
Obtiene id y created_at del usuario, priorizando Redis.
|
|
17
|
+
Si no está en Redis, consulta DB y lo guarda.
|
|
18
|
+
"""
|
|
19
|
+
redis = get_redis_client()
|
|
20
|
+
key = f"cashing:user_auth:{email}"
|
|
21
|
+
|
|
22
|
+
cached_data = await redis.get(key)
|
|
23
|
+
if cached_data:
|
|
24
|
+
try:
|
|
25
|
+
return json.loads(cached_data)
|
|
26
|
+
except json.JSONDecodeError:
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
# Si no hay caché, buscar en DB
|
|
30
|
+
result = await session.exec(select(User).where(User.email == email))
|
|
31
|
+
user = result.one_or_none()
|
|
32
|
+
|
|
33
|
+
if not user:
|
|
34
|
+
raise ValueError(f"User with email {email} not found")
|
|
35
|
+
|
|
36
|
+
info: UserAuthCache = {
|
|
37
|
+
"id": user.id,
|
|
38
|
+
"created_at": user.created_at.isoformat()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# Guardar en caché
|
|
42
|
+
await redis.setex(
|
|
43
|
+
key,
|
|
44
|
+
settings.user_cache_ttl,
|
|
45
|
+
json.dumps(info)
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
return info
|
|
49
|
+
|
|
50
|
+
async def increment_request_count(user_id: int) -> int:
|
|
51
|
+
"""Incrementa el contador de peticiones del usuario en Redis."""
|
|
52
|
+
redis = get_redis_client()
|
|
53
|
+
key = f"cashing:{user_id}:requests"
|
|
54
|
+
|
|
55
|
+
# Incrementa y devuelve el nuevo valor
|
|
56
|
+
return await redis.incr(key)
|
|
57
|
+
|
|
58
|
+
async def reset_request_count(user_id: int):
|
|
59
|
+
"""Resetea el contador de peticiones del usuario en Redis."""
|
|
60
|
+
redis = get_redis_client()
|
|
61
|
+
key = f"cashing:{user_id}:requests"
|
|
62
|
+
await redis.set(key, 0)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cashing2Fast FastAPI Settings
|
|
3
|
+
|
|
4
|
+
Configuration for cashing2fast-fastapi module using pydantic-settings.
|
|
5
|
+
Reads from environment variables with CASHING_ prefix.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from pydantic import BaseModel, SecretStr
|
|
10
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
11
|
+
|
|
12
|
+
# Look for .env in the current working directory (where the app is running)
|
|
13
|
+
DOTENV_PATH = os.path.join(os.getcwd(), ".env")
|
|
14
|
+
|
|
15
|
+
class RedisSettings(BaseModel):
|
|
16
|
+
"""Redis configuration for caching billing info."""
|
|
17
|
+
host: str = "localhost"
|
|
18
|
+
port: int = 6379
|
|
19
|
+
db: int = 0
|
|
20
|
+
password: SecretStr | None = None
|
|
21
|
+
decode_responses: bool = True
|
|
22
|
+
|
|
23
|
+
class Settings(BaseSettings):
|
|
24
|
+
"""Billing and request limit configuration settings."""
|
|
25
|
+
|
|
26
|
+
model_config = SettingsConfigDict(
|
|
27
|
+
env_file=DOTENV_PATH,
|
|
28
|
+
env_file_encoding="utf-8",
|
|
29
|
+
env_prefix="CASHING_",
|
|
30
|
+
env_nested_delimiter="__",
|
|
31
|
+
extra="ignore",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Redis settings
|
|
35
|
+
redis: RedisSettings = RedisSettings()
|
|
36
|
+
|
|
37
|
+
# Feature flags
|
|
38
|
+
redis_enabled: bool = True
|
|
39
|
+
|
|
40
|
+
# Billing Logic Limits
|
|
41
|
+
# Period where users are never penalized/redirected (in minutes)
|
|
42
|
+
free_minutes: int = 0
|
|
43
|
+
# Period following the free minutes where users are tracked (in minutes)
|
|
44
|
+
redirect_minutes: int = 60
|
|
45
|
+
# Number of allowed requests during the redirect_minutes phase
|
|
46
|
+
max_requests: int = 100
|
|
47
|
+
|
|
48
|
+
# Cache settings
|
|
49
|
+
# Default TTL for cached user info (created_at)
|
|
50
|
+
user_cache_ttl: int = 86400 # 24 hours
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
settings = Settings()
|
|
54
|
+
except Exception as e:
|
|
55
|
+
# Use log2fast_fastapi for proper error logging if available
|
|
56
|
+
try:
|
|
57
|
+
from log2fast_fastapi import get_logger
|
|
58
|
+
logger = get_logger(__name__)
|
|
59
|
+
logger.exception(
|
|
60
|
+
"🚨 Error loading Cashing2Fast configuration",
|
|
61
|
+
extra_data={
|
|
62
|
+
"error": str(e),
|
|
63
|
+
"dotenv_path": DOTENV_PATH,
|
|
64
|
+
},
|
|
65
|
+
)
|
|
66
|
+
except ImportError:
|
|
67
|
+
print(f"🚨 Error loading Cashing2Fast configuration: {e}")
|
|
68
|
+
raise
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from redis.asyncio import Redis
|
|
2
|
+
from ..settings import settings
|
|
3
|
+
|
|
4
|
+
# Global redis pool
|
|
5
|
+
_redis_client: Redis | None = None
|
|
6
|
+
|
|
7
|
+
def get_redis_client() -> Redis:
|
|
8
|
+
"""Get or initialize the Redis client from settings."""
|
|
9
|
+
global _redis_client
|
|
10
|
+
if _redis_client is None:
|
|
11
|
+
_redis_client = Redis(
|
|
12
|
+
host=settings.redis.host,
|
|
13
|
+
port=settings.redis.port,
|
|
14
|
+
db=settings.redis.db,
|
|
15
|
+
password=settings.redis.password.get_secret_value() if settings.redis.password else None,
|
|
16
|
+
decode_responses=settings.redis.decode_responses,
|
|
17
|
+
)
|
|
18
|
+
return _redis_client
|
|
19
|
+
|
|
20
|
+
async def close_redis() -> None:
|
|
21
|
+
"""Close the Redis connection pool."""
|
|
22
|
+
global _redis_client
|
|
23
|
+
if _redis_client is not None:
|
|
24
|
+
await _redis_client.aclose()
|
|
25
|
+
_redis_client = None
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cashing2fast-fastapi
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Billing and request limit control for FastAPI
|
|
5
|
+
Author-email: Angel Daniel Sanchez Castillo <angeldaniel.sanchezcastillo@gmail.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Angel Daniel Sanchez Castillo
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Keywords: fastapi,billing,request-limit,redis,oauth2
|
|
29
|
+
Classifier: Development Status :: 3 - Alpha
|
|
30
|
+
Classifier: Intended Audience :: Developers
|
|
31
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
32
|
+
Classifier: Programming Language :: Python :: 3
|
|
33
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
34
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
36
|
+
Classifier: Framework :: FastAPI
|
|
37
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
38
|
+
Requires-Python: >=3.10
|
|
39
|
+
Description-Content-Type: text/markdown
|
|
40
|
+
License-File: LICENSE
|
|
41
|
+
Requires-Dist: oauth2fast-fastapi>=0.3.0
|
|
42
|
+
Requires-Dist: tools2fast-fastapi>=0.1.5
|
|
43
|
+
Requires-Dist: redis>=5.0.0
|
|
44
|
+
Requires-Dist: pydantic-settings>=2.0.0
|
|
45
|
+
Dynamic: license-file
|
|
46
|
+
|
|
47
|
+
# cashing2fast-fastapi
|
|
48
|
+
🚀 Simple and fast cashing tools for FastAPI with minimal configuration
|
|
49
|
+
|
|
50
|
+
## Documentación
|
|
51
|
+
|
|
52
|
+
- [Guía de Uso (Usage Guide)](docs/usage.md)
|
|
53
|
+
- [Ejemplos de configuración](examples/.env.examples)
|
|
54
|
+
|
|
55
|
+
## Instalación
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
uv add cashing2fast-fastapi
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Características
|
|
62
|
+
|
|
63
|
+
- 🎯 **Lógica de 3 Fases:** Tiempo gratuito, periodo de cobro por peticiones y bloqueo final.
|
|
64
|
+
- ⚡ **Redis Native:** Contadores atómicos y caché de usuario para evitar cargas innecesarias a DB.
|
|
65
|
+
- 🛡️ **Tools2Fast Integration:** Respuestas con formato estándar y código HTTP 402.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
MANIFEST.in
|
|
3
|
+
README.md
|
|
4
|
+
pyproject.toml
|
|
5
|
+
docs/usage.md
|
|
6
|
+
examples/.env.examples
|
|
7
|
+
src/cashing2fast_fastapi/__init__.py
|
|
8
|
+
src/cashing2fast_fastapi/__version__.py
|
|
9
|
+
src/cashing2fast_fastapi/dependencies.py
|
|
10
|
+
src/cashing2fast_fastapi/exceptions.py
|
|
11
|
+
src/cashing2fast_fastapi/settings.py
|
|
12
|
+
src/cashing2fast_fastapi.egg-info/PKG-INFO
|
|
13
|
+
src/cashing2fast_fastapi.egg-info/SOURCES.txt
|
|
14
|
+
src/cashing2fast_fastapi.egg-info/dependency_links.txt
|
|
15
|
+
src/cashing2fast_fastapi.egg-info/requires.txt
|
|
16
|
+
src/cashing2fast_fastapi.egg-info/top_level.txt
|
|
17
|
+
src/cashing2fast_fastapi/services/__init__.py
|
|
18
|
+
src/cashing2fast_fastapi/services/billing_service.py
|
|
19
|
+
src/cashing2fast_fastapi/utils/__init__.py
|
|
20
|
+
src/cashing2fast_fastapi/utils/redis_client.py
|
|
21
|
+
tests/test_billing.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cashing2fast_fastapi
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from datetime import datetime, timezone, timedelta
|
|
3
|
+
from unittest.mock import AsyncMock, patch
|
|
4
|
+
from fastapi import FastAPI, Depends
|
|
5
|
+
from httpx import AsyncClient, ASGITransport
|
|
6
|
+
from cashing2fast_fastapi import require_billing_checks, settings
|
|
7
|
+
from cashing2fast_fastapi.dependencies import register_billing_exception_handler
|
|
8
|
+
|
|
9
|
+
# Mock app
|
|
10
|
+
app = FastAPI()
|
|
11
|
+
register_billing_exception_handler(app)
|
|
12
|
+
|
|
13
|
+
@app.get("/test")
|
|
14
|
+
async def test_route(dep = Depends(require_billing_checks)):
|
|
15
|
+
return {"message": "success"}
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def mock_redis():
|
|
19
|
+
with patch("cashing2fast_fastapi.services.billing_service.get_redis_client") as mock:
|
|
20
|
+
client = AsyncMock()
|
|
21
|
+
mock.return_value = client
|
|
22
|
+
yield client
|
|
23
|
+
|
|
24
|
+
@pytest.fixture
|
|
25
|
+
def mock_verify_token():
|
|
26
|
+
with patch("cashing2fast_fastapi.dependencies.verify_token") as mock:
|
|
27
|
+
yield mock
|
|
28
|
+
|
|
29
|
+
@pytest.mark.asyncio
|
|
30
|
+
async def test_phase_1_free(mock_redis, mock_verify_token):
|
|
31
|
+
"""Phase 1: User created recently, should allow without counting."""
|
|
32
|
+
mock_verify_token.return_value = {"sub": "test@example.com"}
|
|
33
|
+
|
|
34
|
+
# Mock user info in Redis (Phase 1: now - 5 mins)
|
|
35
|
+
created_at = datetime.now(timezone.utc) - timedelta(minutes=5)
|
|
36
|
+
mock_redis.get.return_value = '{"id": 1, "created_at": "' + created_at.isoformat() + '"}'
|
|
37
|
+
|
|
38
|
+
# Settings: free=10, redirect=60
|
|
39
|
+
with patch.object(settings, "free_minutes", 10):
|
|
40
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
|
|
41
|
+
response = await ac.get("/test", headers={"Authorization": "Bearer token"})
|
|
42
|
+
|
|
43
|
+
assert response.status_code == 200
|
|
44
|
+
assert response.json() == {"message": "success"}
|
|
45
|
+
# Should NOT increment count in phase 1
|
|
46
|
+
mock_redis.incr.assert_not_called()
|
|
47
|
+
|
|
48
|
+
@pytest.mark.asyncio
|
|
49
|
+
async def test_phase_2_tracked_ok(mock_redis, mock_verify_token):
|
|
50
|
+
"""Phase 2: User in tracking period, should count and allow if below max."""
|
|
51
|
+
mock_verify_token.return_value = {"sub": "test@example.com"}
|
|
52
|
+
|
|
53
|
+
# Mock user info in Redis (Phase 2: now - 20 mins, where free=10)
|
|
54
|
+
created_at = datetime.now(timezone.utc) - timedelta(minutes=20)
|
|
55
|
+
mock_redis.get.return_value = '{"id": 1, "created_at": "' + created_at.isoformat() + '"}'
|
|
56
|
+
# Current count = 1
|
|
57
|
+
mock_redis.incr.return_value = 1
|
|
58
|
+
|
|
59
|
+
with patch.object(settings, "free_minutes", 10):
|
|
60
|
+
with patch.object(settings, "redirect_minutes", 60):
|
|
61
|
+
with patch.object(settings, "max_requests", 5):
|
|
62
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
|
|
63
|
+
response = await ac.get("/test", headers={"Authorization": "Bearer token"})
|
|
64
|
+
|
|
65
|
+
assert response.status_code == 200
|
|
66
|
+
mock_redis.incr.assert_called_once_with("cashing:1:requests")
|
|
67
|
+
|
|
68
|
+
@pytest.mark.asyncio
|
|
69
|
+
async def test_phase_2_tracked_limit_reached(mock_redis, mock_verify_token):
|
|
70
|
+
"""Phase 2: User reached limit, should return 402 and reset."""
|
|
71
|
+
mock_verify_token.return_value = {"sub": "test@example.com"}
|
|
72
|
+
|
|
73
|
+
created_at = datetime.now(timezone.utc) - timedelta(minutes=20)
|
|
74
|
+
mock_redis.get.return_value = '{"id": 1, "created_at": "' + created_at.isoformat() + '"}'
|
|
75
|
+
# Current count = 6 (limit is 5)
|
|
76
|
+
mock_redis.incr.return_value = 6
|
|
77
|
+
|
|
78
|
+
with patch.object(settings, "free_minutes", 10):
|
|
79
|
+
with patch.object(settings, "redirect_minutes", 60):
|
|
80
|
+
with patch.object(settings, "max_requests", 5):
|
|
81
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
|
|
82
|
+
response = await ac.get("/test", headers={"Authorization": "Bearer token"})
|
|
83
|
+
|
|
84
|
+
assert response.status_code == 402
|
|
85
|
+
assert response.json()["message"] == "Límite de peticiones alcanzado. Por favor, realiza un pago."
|
|
86
|
+
# Should reset count
|
|
87
|
+
mock_redis.set.assert_called_with("cashing:1:requests", 0)
|
|
88
|
+
|
|
89
|
+
@pytest.mark.asyncio
|
|
90
|
+
async def test_phase_3_expired(mock_redis, mock_verify_token):
|
|
91
|
+
"""Phase 3: Period expired, should block always."""
|
|
92
|
+
mock_verify_token.return_value = {"sub": "test@example.com"}
|
|
93
|
+
|
|
94
|
+
# Phase 3: now - 100 mins (free=10 + redirect=60 = 70 mins total period)
|
|
95
|
+
created_at = datetime.now(timezone.utc) - timedelta(minutes=100)
|
|
96
|
+
mock_redis.get.return_value = '{"id": 1, "created_at": "' + created_at.isoformat() + '"}'
|
|
97
|
+
|
|
98
|
+
with patch.object(settings, "free_minutes", 10):
|
|
99
|
+
with patch.object(settings, "redirect_minutes", 60):
|
|
100
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
|
|
101
|
+
response = await ac.get("/test", headers={"Authorization": "Bearer token"})
|
|
102
|
+
|
|
103
|
+
assert response.status_code == 402
|
|
104
|
+
assert "expirado" in response.json()["message"]
|