diff --git a/docs/tutorials/hello_world.md b/docs/tutorials/hello_world.md index 94cffe7..3955d55 100644 --- a/docs/tutorials/hello_world.md +++ b/docs/tutorials/hello_world.md @@ -119,6 +119,4 @@ Swagger docs are available at: ### βœ… What's Next? -* Add **Beanie** or **SQLAlchemy** models β€” pAPI will detect and initialize them automatically -* Add custom CLI commands in `cli.py` * Implement `AddonSetupHook` for custom startup logic diff --git a/docs/tutorials/hello_world_2.md b/docs/tutorials/hello_world_2.md index 2226c6e..9e65314 100644 --- a/docs/tutorials/hello_world_2.md +++ b/docs/tutorials/hello_world_2.md @@ -62,3 +62,7 @@ from .addon_setup import HelloWorldAddonSetup That’s all you need! When the addon is loaded, the `startup` method will be executed automatically. When the application is stopped (e.g. by pressing `Ctrl+C` while running with `uvicorn`), the `shutdown` method will be called. + +### βœ… What's Next? + +* Add MongoDB models using **Beanie** diff --git a/docs/tutorials/weather.md b/docs/tutorials/weather_mongodb.md similarity index 96% rename from docs/tutorials/weather.md rename to docs/tutorials/weather_mongodb.md index 98b3797..8b76584 100644 --- a/docs/tutorials/weather.md +++ b/docs/tutorials/weather_mongodb.md @@ -1,4 +1,4 @@ -## 🌦️ Weather Addon Example +# 🌦️ Weather Addon Example (MongoDB Version) This addon demonstrates how to build a basic weather data API using **pAPI**, with persistent storage in MongoDB via the integrated **Beanie** ODM. @@ -227,7 +227,7 @@ rye run python papi/cli.py webserver ```bash curl -X 'POST' \ - 'http://localhost:8080/stations' \ + 'http://localhost:8000/stations' \ -H 'accept: application/json' \ -H 'Content-Type: application/json' \ -d '{ @@ -265,7 +265,7 @@ curl -X 'POST' \ ```bash curl -X 'GET' \ - 'http://localhost:8080/stations' \ + 'http://localhost:8000/stations' \ -H 'accept: application/json' ``` @@ -299,7 +299,7 @@ curl -X 'GET' \ ```bash curl -X 'GET' \ - 'http://localhost:8080/stations/684da177ebcda212e2ce8dac/weather' \ + 'http://localhost:8000/stations/684da177ebcda212e2ce8dac/weather' \ -H 'accept: application/json' ``` @@ -361,3 +361,7 @@ Confirm the change: In [7]: await mongo_documents["WeatherStation"].get(ObjectId('684daa34dc94122d9d84bac9')) Out[7]: WeatherStation(name='New Santa Clara', ...) ``` + +### βœ… What's Next? + +* Add SQL models using **SQLAlchemy** diff --git a/docs/tutorials/weather_sqlalchemy.md b/docs/tutorials/weather_sqlalchemy.md new file mode 100644 index 0000000..f1a319a --- /dev/null +++ b/docs/tutorials/weather_sqlalchemy.md @@ -0,0 +1,337 @@ +# 🌦️ Weather Addon Example (SQLAlchemy Version) + +This addon showcases how to build a basic weather data API using **pAPI**, now powered by **SQLAlchemy** for persistent storage. + +With this example, you will learn how to: + +* Integrate an SQL database using SQLAlchemy's async engine +* Declare and manage Python dependencies within your addon +* Register and list weather stations +* Retrieve and save real-time weather data from an external API + +--- + +### πŸ—‚οΈ Project Structure + +``` +my_addons/ +└── weather/ + β”œβ”€β”€ __init__.py + β”œβ”€β”€ manifest.yaml + β”œβ”€β”€ models.py + β”œβ”€β”€ schemas.py + β”œβ”€β”€ crud.py + └── routers.py +``` + +--- + +### πŸ“„ `manifest.yaml` + +Defines the addon metadata and required Python packages: + +```yaml +name: weather +version: 1.0.0 +description: Weather data API (SQLAlchemy version) +author: Your Name + +python_dependencies: + - "requests>=2.28.0" +``` + +--- + +### 🧬 `models.py` + +```python +from datetime import datetime + +from sqlalchemy import Column, DateTime, Float, ForeignKey, Integer, String +from sqlalchemy.orm import declarative_base, relationship + +Base = declarative_base() + +class WeatherStation(Base): + __tablename__ = "weather_stations" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False) + latitude = Column(Float, nullable=False) + longitude = Column(Float, nullable=False) + created_at = Column(DateTime, default=datetime.utcnow) + + readings = relationship("WeatherReading", back_populates="station") + + +class WeatherReading(Base): + __tablename__ = "weather_readings" + + id = Column(Integer, primary_key=True, index=True) + station_id = Column(Integer, ForeignKey("weather_stations.id")) + temperature = Column(Float) + windspeed = Column(Float) + humidity = Column(Float) + timestamp = Column(DateTime, default=datetime.utcnow) + + station = relationship("WeatherStation", back_populates="readings") +``` + +--- + +### πŸ“Š `schemas.py` + +```python +from datetime import datetime +from pydantic import BaseModel + +class WeatherStationBase(BaseModel): + name: str + latitude: float + longitude: float + +class WeatherStationCreate(WeatherStationBase): + pass + +class WeatherStationOut(WeatherStationBase): + id: int + created_at: datetime + + class Config: + orm_mode = True + +class WeatherReadingOut(BaseModel): + id: int + station_id: int + temperature: float | None + windspeed: float | None + humidity: float | None + timestamp: datetime + + class Config: + orm_mode = True +``` + +--- + +### 🌐 `crud.py` + +```python +import requests + +def get_weather(latitude: float, longitude: float) -> dict: + url = ( + "https://api.open-meteo.com/v1/forecast" + f"?latitude={latitude}&longitude={longitude}" + "¤t=temperature_2m,wind_speed_10m,relative_humidity_2m" + ) + + try: + response = requests.get(url, timeout=5) + response.raise_for_status() + current = response.json().get("current") + if not current: + raise ValueError("Missing 'current' field in API response") + + return { + "temperature": current["temperature_2m"], + "windspeed": current["wind_speed_10m"], + "humidity": current["relative_humidity_2m"] + } + + except Exception as e: + return { + "temperature": None, + "windspeed": None, + "humidity": None, + "error": str(e) + } +``` + +--- + + +### πŸ”Œ `routers.py` + +pAPI provides the `sql_session` dependency, which you can use directly as a router dependency in your route functions. Alternatively, you can use the asynchronous context manager `get_sql_session` that yields a SQLAlchemy session within an async context. + +--- + +```python +from fastapi import Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from papi.core.db import sql_session, get_sql_session +from papi.core.router import RESTRouter + +from . import models, schemas +from .crud import get_weather + +router = RESTRouter() + + +@router.post("/stations", response_model=schemas.WeatherStationOut) +async def create_station( + station: schemas.WeatherStationCreate, db: AsyncSession = Depends(sql_session) +): + new_station = models.WeatherStation( + name=station.name, latitude=station.latitude, longitude=station.longitude + ) + db.add(new_station) + await db.commit() + await db.refresh(new_station) + return new_station + + +@router.get("/stations", response_model=list[schemas.WeatherStationOut]) +async def list_stations(db: AsyncSession = Depends(sql_session)): + result = await db.execute(select(models.WeatherStation)) + return result.scalars().all() + + +@router.get("/stations/{station_id}/weather", response_model=schemas.WeatherReadingOut) +async def get_current_weather(station_id: int): + # Using get_sql_session context here + async with get_sql_session() as session: + result = await session.execute( + select(models.WeatherStation).where(models.WeatherStation.id == station_id) + ) + station = result.scalar_one_or_none() + if not station: + raise HTTPException(status_code=404, detail="Station not found") + + weather = get_weather(station.latitude, station.longitude) + reading = models.WeatherReading( + station_id=station.id, + temperature=weather["temperature"], + windspeed=weather["windspeed"], + humidity=weather["humidity"], + ) + session.add(reading) + await session.commit() + await session.refresh(reading) + return reading +``` + +--- + +### πŸ“† `__init__.py` + +```python +from . import models, routers + +__all__ = ["router","models"] +``` + +--- + +### βš™οΈ Main papi configuration (`config.yaml`) + +```yaml +# Base configuration – see the Hello World example +... + +# SQLAlchemy connection settings (example using SQLite) +database: + sqlalchemy_uri: "sqlite+aiosqlite:///./weather.db" + backends: + sqlalchemy: + echo: false # Optional: enables SQL query logging + +# Enable the weather addon +addons: + extra_addons_path: "my_addons" + enabled: + - weather +``` + +pAPI allows fine-tuning of the database engine by providing additional configuration under the `backends` section in `config.yaml`. + +--- + +## 🚜 How to Use + +### πŸš€ Start the API Server + +```bash +rye run python papi/cli.py webserver +``` +Once the pAPI server is started, the system will automatically detect the SQLAlchemy models and route definitions, initialize the corresponding database tables, and register the API endpoints with the main FastAPI application. + +**Add a station:** + +```bash +curl -X 'POST' \ + 'http://127.0.0.1:8000/stations' \ + -H 'accept: application/json' \ + -H 'Content-Type: application/json' \ + -d '{ + "name": "Santa Clara, Cuba", + "latitude": 22.4067, + "longitude": -79.9531 +}' +``` + +**Response:** + +```json +{ + "id": 1, + "name": "Santa Clara, Cuba", + "latitude": 22.4067, + "longitude": -79.9531, + "created_at": "2025-06-25T14:00:14.162273" +} +``` + +--- + +**List all stations:** + +```bash +curl -X 'GET' \ + 'http://localhost:8080/stations' \ + -H 'accept: application/json' +``` + +**Response:** + +```json +[ + { + "id": 1, + "name": "Santa Clara, Cuba", + "latitude": 22.4067, + "longitude": -79.9531, + "created_at": "2025-06-25T14:00:14.162273" + } +] +``` + +--- + +**Get weather data for station 1:** + +```bash +curl -X 'GET' \ + 'http://localhost:8000/stations/1/weather' \ + -H 'accept: application/json' +``` + +**Response:** + +```json +{ + "id": 1, + "station_id": 1, + "temperature": 28.3, + "windspeed": 15.4, + "humidity": 71.0, + "timestamp": "2025-06-25T14:05:48.300713" +} +``` + +### βœ… What's Next? + +* Serve static files diff --git a/docs/tutorials/website.md b/docs/tutorials/website.md new file mode 100644 index 0000000..9793217 --- /dev/null +++ b/docs/tutorials/website.md @@ -0,0 +1,204 @@ +# Simple Website + +In this tutorial, we demonstrate how to use static files within each addon module. +Although the main focus of pAPI, like FastAPI, is on building APIs rather than full-stack web applications (like Django), pAPI can still serve static files and web pages easily. + +This example is not intended to cover template engines or advanced web development techniques. Instead, it focuses on how to serve static assets using a basic HTML response as an example. + +During the addon discovery and initialization process, pAPI will automatically detect a `static` folder at the root of your addon. If such a folder exists, it will be mounted as a static file directory for your module (and made available globally across all modules). This allows you to structure your addon as follows: + +--- + +### πŸ—‚οΈ Project Structure + +```bash +my_addons/ +└── website/ + β”œβ”€β”€ static/ + β”‚ └── style.css + β”œβ”€β”€ __init__.py + β”œβ”€β”€ manifest.yaml + └── routers.py +``` + +--- + +### 🎨 `style.css` + +Here's the styling for our simple page: + +```css +body { + font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; + background-color: #f9fafb; + margin: 0; + padding: 0; + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + color: #2c3e50; +} +.container { + text-align: center; + background-color: #ffffff; + padding: 2.5rem 3rem; + border-radius: 1rem; + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1); + max-width: 500px; +} +h1 { + font-size: 2.25rem; + margin-bottom: 0.5rem; +} +.subtitle { + font-size: 1.1rem; + color: #7f8c8d; + margin-bottom: 1.5rem; +} +.status-dot { + display: inline-block; + width: 12px; + height: 12px; + border-radius: 50%; + background-color: #2ecc71; + margin-right: 8px; + vertical-align: middle; +} +.footer { + margin-top: 2rem; + font-size: 0.9rem; + color: #95a5a6; +} +b { + font-weight: 600; +} +.btn { + display: inline-block; + margin-top: 1.5rem; + padding: 0.75rem 1.5rem; + font-size: 1rem; + color: #fff; + background-color: #3498db; + border: none; + border-radius: 8px; + text-decoration: none; + transition: background-color 0.3s ease; +} +.btn:hover { + background-color: #2980b9; +} +``` + +--- + +### 🌐 `routers.py` + +```python +from fastapi.responses import HTMLResponse +from papi.core.router import RESTRouter + +website_router = RESTRouter() + + +@website_router.http("/") +async def website_index(): + html_content = """ + + + + + + pAPI - Pluggable API + + + +
+

pAPI

+

pluggable API platform

+

Server is online and ready

+ πŸ“˜ Open API Docs + +
+ + + """ + return HTMLResponse(content=html_content, status_code=200) +``` + +Note: Static files will be available under `/addons_name/` path prefix. + +--- + +### πŸ“¦ `__init__.py` + +To activate the routes, just import the `routers.py` in your module: + +```python +from . import routes +``` + +--- + +### πŸ“„ `manifest.yaml` + +```yaml +title: "Website Module" +version: "0.1.0" +description: "Base module for public website interface and assets." +``` + +--- + +### βš™οΈ `config.yaml` + +Activate the addon by adding it to your configuration: + +```yaml +# Base configuration – see the Hello World example +... + +addons: + extra_addons_path: "my_addons" + enabled: + - website +``` + +--- + +### πŸš€ Launch the server + +```bash +rye run python papi/cli.py webserver +``` + +--- + +### πŸ—ƒοΈ Global Static File Storage + +pAPI also provides a global static file configuration, designed for serving static assets like images, documents, videos, etc., independently of any addon. This can be useful when your API needs to act as a file server. + +You can configure it in the `config.yaml` under the `storage` section. Although this feature wasn’t originally intended for serving stylesheets, you can use it creatively, like so: + +```yaml +# Base configuration – see the Hello World example +... + +addons: + extra_addons_path: "my_addons" + enabled: + - website + +storage: + styles: my_addons/website/static +``` + +Then, in your HTML, reference static assets like this: + +```html + +``` + +This approach allows centralized serving of static assets across your entire API. diff --git a/mkdocs.yml b/mkdocs.yml index 2185a99..1a72296 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -9,7 +9,9 @@ nav: - Getting started: - Your first Addon: tutorials/hello_world.md - Addon Life cycle: tutorials/hello_world_2.md - - The weather example: tutorials/weather.md + - Weather API (MongoDB): tutorials/weather_mongodb.md + - Weather API (SQL): tutorials/weather_sqlalchemy.md + - Static Files: tutorials/website.md - API Reference: - pAPI Core: - Router: reference/router.md diff --git a/papi/cli.py b/papi/cli.py index 9abfefa..2229af3 100644 --- a/papi/cli.py +++ b/papi/cli.py @@ -145,7 +145,7 @@ async def run_api_server(app: FastAPI) -> AsyncGenerator: static_path = Path(module.__path__[0]) / "static" if static_path.is_dir(): app.mount( - f"/static/{addon_id}", + f"/{addon_id}", StaticFiles(directory=static_path), name=f"{addon_id}_static", ) diff --git a/papi/core/db/redis/redis.py b/papi/core/db/redis/redis.py index b0b81d3..43efa81 100644 --- a/papi/core/db/redis/redis.py +++ b/papi/core/db/redis/redis.py @@ -40,12 +40,13 @@ async def get_redis_client() -> Optional[Redis]: return _redis config = get_config() + redis_backend = None if config.database: redis_backend = config.database.get_backend("redis") return None - if not redis_backend.url: + if not redis_backend or not redis_backend.url: logger.warning( "Redis URI is not configured. Redis client will not be initialized." ) diff --git a/papi/core/db/sql/sql_session.py b/papi/core/db/sql/sql_session.py index 93d6159..79fad94 100644 --- a/papi/core/db/sql/sql_session.py +++ b/papi/core/db/sql/sql_session.py @@ -40,7 +40,7 @@ async def get_sql_session() -> AsyncGenerator[AsyncSession, None]: sql_alchemy_cfg = config.database.get_backend("sqlalchemy").get_defined_fields() # Validate configuration - if not sql_uri: + if "url" not in sql_alchemy_cfg: log.critical("Database SQL_URI not configured") raise RuntimeError("Database configuration missing: SQL_URI not set") diff --git a/papi/core/models/config.py b/papi/core/models/config.py index 4b448c4..ef1620b 100644 --- a/papi/core/models/config.py +++ b/papi/core/models/config.py @@ -19,17 +19,9 @@ class StorageConfig(BaseModel): """ Configuration for storage backends. - - Attributes: - files (Optional[str]): Base path or URI for file storage. - images (Optional[str]): Base path or URI for image storage. - Extra fields are allowed and will be preserved. """ - files: Optional[str] = "" - images: Optional[str] = "" - class Config: extra = "allow"