diff --git a/docker-compose.yml b/docker-compose.yml index 686ad9fa..1a70a7c0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,7 +13,7 @@ services: interval: 5s timeout: 5s retries: 10 - postgres: + db: image: postgres:17 environment: POSTGRES_DB: database_app_test diff --git a/example/config-app/uv.lock b/example/config-app/uv.lock index 72f1cab2..9cdb486a 100644 --- a/example/config-app/uv.lock +++ b/example/config-app/uv.lock @@ -186,13 +186,15 @@ requires-dist = [ { name = "fastapi", extras = ["standard"], marker = "extra == 'fastapi'", specifier = ">=0.124.4,<0.125.0" }, { name = "inflection", specifier = ">=0.5.1" }, { name = "itsdangerous", marker = "extra == 'fastapi'", specifier = ">=2.2.0" }, + { name = "jinja2", marker = "extra == 'inertia'", specifier = ">=3.1" }, { name = "jinja2", marker = "extra == 'vite'", specifier = ">=3.1" }, + { name = "markupsafe", marker = "extra == 'inertia'", specifier = ">=2.0" }, { name = "pendulum", specifier = ">=3.1.0,<4.0.0" }, { name = "pydantic", specifier = ">=2.12.5" }, { name = "requests", specifier = ">=2.32.5,<3.0.0" }, { name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'database'", specifier = ">=2.0.38" }, ] -provides-extras = ["fastapi", "database", "sqlite", "postgres", "mysql", "vite"] +provides-extras = ["fastapi", "database", "sqlite", "postgres", "mysql", "vite", "inertia"] [package.metadata.requires-dev] dev = [ diff --git a/example/database-app/app/students/controllers/registration.py b/example/database-app/app/students/controllers/registration.py index b4b085d7..d6b27f84 100644 --- a/example/database-app/app/students/controllers/registration.py +++ b/example/database-app/app/students/controllers/registration.py @@ -1,6 +1,6 @@ import hashlib -from fastapi import HTTPException +from fastapi.exceptions import RequestValidationError from app.http.schemas.auth import StudentRegistrationRequest from app.models import User, Profile @@ -9,7 +9,14 @@ async def register(request: StudentRegistrationRequest): existing_user = await User.where("email", request.email).first() if existing_user: - raise HTTPException(status_code=400, detail="Email already registered") + raise RequestValidationError( + errors=[{ + "loc": ("body", "email"), + "msg": "Email already registered", + "type": "value_error", + "input": request.email, + }] + ) password = hashlib.md5(request.password.encode()).hexdigest() user = User( diff --git a/example/database-app/tests/features/students/test_register.py b/example/database-app/tests/features/students/test_register.py index 08c680c3..408d051c 100644 --- a/example/database-app/tests/features/students/test_register.py +++ b/example/database-app/tests/features/students/test_register.py @@ -61,5 +61,6 @@ async def test_user_cannot_register_with_duplicate_email(self): await self.post("/students/register", json=payload) response = await self.post("/students/register", json=payload) - assert response.status_code == 400 - assert response.json()["detail"] == "Email already registered" + assert response.status_code == 422 + errors = response.json()["errors"] + assert "email" in errors diff --git a/example/database-app/uv.lock b/example/database-app/uv.lock index b98aa2b0..56a1b993 100644 --- a/example/database-app/uv.lock +++ b/example/database-app/uv.lock @@ -535,13 +535,15 @@ requires-dist = [ { name = "fastapi", extras = ["standard"], marker = "extra == 'fastapi'", specifier = ">=0.124.4,<0.125.0" }, { name = "inflection", specifier = ">=0.5.1" }, { name = "itsdangerous", marker = "extra == 'fastapi'", specifier = ">=2.2.0" }, + { name = "jinja2", marker = "extra == 'inertia'", specifier = ">=3.1" }, { name = "jinja2", marker = "extra == 'vite'", specifier = ">=3.1" }, + { name = "markupsafe", marker = "extra == 'inertia'", specifier = ">=2.0" }, { name = "pendulum", specifier = ">=3.1.0,<4.0.0" }, { name = "pydantic", specifier = ">=2.12.5" }, { name = "requests", specifier = ">=2.32.5,<3.0.0" }, { name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'database'", specifier = ">=2.0.38" }, ] -provides-extras = ["fastapi", "database", "sqlite", "postgres", "mysql", "vite"] +provides-extras = ["fastapi", "database", "sqlite", "postgres", "mysql", "vite", "inertia"] [package.metadata.requires-dev] dev = [ diff --git a/example/inertia-pingcrm-app/.env.example b/example/inertia-pingcrm-app/.env.example new file mode 100644 index 00000000..34169464 --- /dev/null +++ b/example/inertia-pingcrm-app/.env.example @@ -0,0 +1,18 @@ +APP_NAME="Inertia Tickets" +APP_ENV=local +APP_URL=http://localhost:8000 +APP_DEBUG=true + +DB_CONNECTION=postgres +DB_HOST=127.0.0.1 +DB_PORT=5432 +DB_DATABASE=database_app_test +DB_USERNAME=app +DB_PASSWORD=secret + +AWS_ENDPOINT=http://localhost:9000 +AWS_ACCESS_KEY_ID=minioadmin +AWS_SECRET_ACCESS_KEY=minioadmin +AWS_BUCKET=pingcrm +AWS_DEFAULT_REGION=us-east-1 +AWS_URL=http://localhost:9000/uploads diff --git a/example/inertia-pingcrm-app/app/http/controllers/images_controller.py b/example/inertia-pingcrm-app/app/http/controllers/images_controller.py index 2d6603bd..adac9dce 100644 --- a/example/inertia-pingcrm-app/app/http/controllers/images_controller.py +++ b/example/inertia-pingcrm-app/app/http/controllers/images_controller.py @@ -1,7 +1,5 @@ -from fastapi import Request -from fastapi.responses import JSONResponse -from fastapi_startkit.inertia import Inertia - -async def show(request: Request): - return JSONResponse(content={'message': 'images_controller.py@show'}) +from fastapi_startkit.storage import Storage +async def stream(path: str): + """Stream a file from S3 back to the client.""" + return Storage.disk("s3").stream(path) diff --git a/example/inertia-pingcrm-app/app/http/controllers/profile_controller.py b/example/inertia-pingcrm-app/app/http/controllers/profile_controller.py new file mode 100644 index 00000000..b07bdcaf --- /dev/null +++ b/example/inertia-pingcrm-app/app/http/controllers/profile_controller.py @@ -0,0 +1,54 @@ +import uuid +from pathlib import Path +from typing import Optional + + +from fastapi import Request, Depends, UploadFile, File +from fastapi.responses import RedirectResponse +from fastapi_startkit.inertia import Inertia +from fastapi_startkit.storage import Storage +from app.models.User import User +from app.http.requests.profile import ProfileUpdateRequest + + +async def save_photo(photo: Optional[UploadFile]) -> Optional[str]: + if photo is None or not photo.filename: + return None + if not photo.content_type or not photo.content_type.startswith("image/"): + return None + ext = Path(photo.filename).suffix.lower() or ".jpg" + filename = f"photos/{uuid.uuid4().hex}{ext}" + Storage.disk("s3").put(filename, await photo.read()) + return filename + + +async def edit(request: Request): + user = await User.find(request.state.user["id"]) + photo_url = f"/images/{user.photo_path}" if user.photo_path else None + return Inertia.render('Profile/Edit', { + 'user': { + 'id': user.id, + 'first_name': user.first_name, + 'last_name': user.last_name, + 'email': user.email, + 'photo': photo_url, + 'password': '', + } + }) + + +async def update( + request: Request, + form: ProfileUpdateRequest = Depends(), + photo: Optional[UploadFile] = File(default=None), +): + user = await User.find(request.state.user["id"]) + + photo_path = await save_photo(photo) + + update_data = form.validated() + if photo_path: + update_data['photo_path'] = photo_path + + await user.update(update_data) + return RedirectResponse(url="/profile", status_code=303) diff --git a/example/inertia-pingcrm-app/app/http/controllers/users_controller.py b/example/inertia-pingcrm-app/app/http/controllers/users_controller.py index 74441c6b..c3980d6b 100644 --- a/example/inertia-pingcrm-app/app/http/controllers/users_controller.py +++ b/example/inertia-pingcrm-app/app/http/controllers/users_controller.py @@ -1,9 +1,29 @@ -from fastapi import Request +import uuid +from pathlib import Path +from typing import Optional + +from fastapi import Request, UploadFile, File, Form from fastapi.responses import RedirectResponse from fastapi_startkit.inertia import Inertia +from fastapi_startkit.storage import Storage from app.models.User import User +async def _save_photo(photo: Optional[UploadFile]) -> Optional[str]: + """Save an UploadFile to the public disk and return its public URL path, or None.""" + if photo is None or not photo.filename: + return None + if not photo.content_type or not photo.content_type.startswith("image/"): + return None + + ext = Path(photo.filename).suffix.lower() or ".jpg" + filename = f"{uuid.uuid4().hex}{ext}" + + Storage.disk("public").put(filename, await photo.read()) + + return f"/storage/{filename}" + + async def index(): users = await User.query().limit(10).get() return Inertia.render('Users/Index', { @@ -32,13 +52,32 @@ async def create(): return Inertia.render('Users/Create', {}) -async def store(request: Request): - form = await request.json() - await User.create(form) +async def store( + request: Request, + first_name: str = Form(...), + last_name: str = Form(...), + email: str = Form(...), + password: str = Form(default=''), + owner: str = Form(default='0'), + photo: Optional[UploadFile] = File(default=None), +): + photo_path = await _save_photo(photo) + + user_data = { + 'first_name': first_name, + 'last_name': last_name, + 'email': email, + 'password': password, + 'owner': owner == '1', + } + if photo_path: + user_data['photo_path'] = photo_path + + await User.create(user_data) return RedirectResponse(url="/users", status_code=303) -async def edit(user: str): +async def edit(user: int): u = await User.find(user) return Inertia.render('Users/Edit', { 'user': { @@ -54,16 +93,38 @@ async def edit(user: str): }) -async def update(request: Request, user: str): +async def update( + request: Request, + user: int, + first_name: str = Form(...), + last_name: str = Form(...), + email: str = Form(...), + password: str = Form(default=''), + owner: str = Form(default='0'), + photo: Optional[UploadFile] = File(default=None), +): u = await User.find(user) - form = await request.json() - await u.update(form) + + photo_path = await _save_photo(photo) + + update_data = { + 'first_name': first_name, + 'last_name': last_name, + 'email': email, + 'owner': owner == '1', + } + if password: + update_data['password'] = password + if photo_path: + update_data['photo_path'] = photo_path + + await u.update(update_data) return RedirectResponse(url=f"/users/{user}/edit", status_code=303) -async def destroy(user: str): +async def destroy(user: int): return RedirectResponse(url="/users", status_code=303) -async def restore(user: str): +async def restore(user: int): return RedirectResponse(url="/users", status_code=303) diff --git a/example/inertia-pingcrm-app/app/http/requests/__init__.py b/example/inertia-pingcrm-app/app/http/requests/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/example/inertia-pingcrm-app/app/http/requests/__init__.py @@ -0,0 +1 @@ + diff --git a/example/inertia-pingcrm-app/app/http/requests/profile.py b/example/inertia-pingcrm-app/app/http/requests/profile.py new file mode 100644 index 00000000..30c4c41a --- /dev/null +++ b/example/inertia-pingcrm-app/app/http/requests/profile.py @@ -0,0 +1,8 @@ +from fastapi_startkit.fastapi import RequestModel + + +class ProfileUpdateRequest(RequestModel): + first_name: str + last_name: str + email: str + password: str = '' diff --git a/example/inertia-pingcrm-app/app/models/User.py b/example/inertia-pingcrm-app/app/models/User.py index f5c77962..dd77941b 100644 --- a/example/inertia-pingcrm-app/app/models/User.py +++ b/example/inertia-pingcrm-app/app/models/User.py @@ -1,5 +1,5 @@ from typing import Optional -from fastapi_startkit.masoniteorm.models import Model +from fastapi_startkit.masoniteorm import Model class User(Model): diff --git a/example/inertia-pingcrm-app/bootstrap/application.py b/example/inertia-pingcrm-app/bootstrap/application.py index 02fd2d78..a4c15289 100644 --- a/example/inertia-pingcrm-app/bootstrap/application.py +++ b/example/inertia-pingcrm-app/bootstrap/application.py @@ -1,6 +1,7 @@ from pathlib import Path from config.database import DatabaseConfig +from config.storage import StorageConfig from providers.fastapi_provider import FastAPIProvider from starlette.middleware.trustedhost import TrustedHostMiddleware @@ -12,6 +13,7 @@ from fastapi_startkit.inertia import InertiaProvider from fastapi_startkit.logging import LogProvider from fastapi_startkit.masoniteorm import DatabaseProvider +from fastapi_startkit.storage.providers.provider import StorageProvider from fastapi_startkit.vite import ViteProvider from starlette.responses import RedirectResponse @@ -28,6 +30,7 @@ def register(self): providers=[ LogProvider, (DatabaseProvider, DatabaseConfig), + (StorageProvider, StorageConfig), FastAPIProvider, ViteProvider, InertiaProvider, diff --git a/example/inertia-pingcrm-app/config/storage.py b/example/inertia-pingcrm-app/config/storage.py new file mode 100644 index 00000000..8c2f22a0 --- /dev/null +++ b/example/inertia-pingcrm-app/config/storage.py @@ -0,0 +1,31 @@ +from dataclasses import dataclass, field +from typing import Any, Dict + +from fastapi_startkit.environment import env +from fastapi_startkit.storage import LocalDiskConfig, PublicDiskConfig, S3Config + + +@dataclass +class StorageConfig: + default: str = field(default_factory=lambda: env("FILESYSTEM_DISK", "local")) + + disks: dict[str, Dict[str, Any]] = field( + default_factory=lambda: { + "local": LocalDiskConfig( + root=env("FILESYSTEM_DISK_ROOT", "storage"), + ), + "public": PublicDiskConfig( + root=env("FILESYSTEM_PUBLIC_DISK_ROOT", "storage/app/public"), + url=env("FILESYSTEM_PUBLIC_DISK_URL", "/storage"), + ), + "s3": S3Config( + key=env("AWS_ACCESS_KEY_ID"), + secret=env("AWS_SECRET_ACCESS_KEY"), + region=env("AWS_DEFAULT_REGION", "us-east-1"), + bucket=env("AWS_BUCKET"), + url=env("AWS_URL"), + endpoint=env("AWS_ENDPOINT"), + use_path_style_endpoint=True, + ), + } + ) \ No newline at end of file diff --git a/example/inertia-pingcrm-app/package.json b/example/inertia-pingcrm-app/package.json index 8a70b8e5..d664a234 100644 --- a/example/inertia-pingcrm-app/package.json +++ b/example/inertia-pingcrm-app/package.json @@ -4,7 +4,7 @@ "type": "module", "version": "0.1.0", "scripts": { - "dev": "vite", + "dev": "concurrently \"npx vite dev\" \"uv run python artisan serve\"", "build": "vite build", "preview": "vite preview" }, diff --git a/example/inertia-pingcrm-app/providers/fastapi_provider.py b/example/inertia-pingcrm-app/providers/fastapi_provider.py index 87c119f7..ccadf0e9 100644 --- a/example/inertia-pingcrm-app/providers/fastapi_provider.py +++ b/example/inertia-pingcrm-app/providers/fastapi_provider.py @@ -1,7 +1,5 @@ from pathlib import Path -from fastapi.responses import RedirectResponse - from authentication.middlewares.auth import AuthMiddleware, NotAuthenticated from fastapi import FastAPI, Request from fastapi_startkit.fastapi import FastAPIProvider as BaseFastAPIProvider diff --git a/example/inertia-pingcrm-app/pyproject.toml b/example/inertia-pingcrm-app/pyproject.toml index c0991bdf..3fa4d2d7 100644 --- a/example/inertia-pingcrm-app/pyproject.toml +++ b/example/inertia-pingcrm-app/pyproject.toml @@ -4,10 +4,12 @@ version = "0.1.0" description = "Vite + FastAPI example using fastapi-startkit" requires-python = ">=3.12" dependencies = [ + "boto3>=1.35.0", "faker>=40.15.0", "fastapi-startkit[fastapi,database,postgres]", "itsdangerous>=2.2.0", "jinja2>=3.1", + "python-multipart>=0.0.9", ] [tool.uv.sources] @@ -16,4 +18,11 @@ fastapi-startkit = { path = "../../fastapi_startkit", editable = true } [dependency-groups] dev = [ "dumpdie>=1.5.0", + "pytest>=8.0", + "pytest-asyncio>=0.24", + "httpx>=0.27", ] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] diff --git a/example/inertia-pingcrm-app/resources/js/Components/Form/FileInput.tsx b/example/inertia-pingcrm-app/resources/js/Components/Form/FileInput.tsx index 73bcf2b3..eeaca34c 100644 --- a/example/inertia-pingcrm-app/resources/js/Components/Form/FileInput.tsx +++ b/example/inertia-pingcrm-app/resources/js/Components/Form/FileInput.tsx @@ -1,15 +1,27 @@ -import React, { useState, useRef, ComponentProps } from 'react'; +import React, { useState, useRef, useEffect, ComponentProps } from 'react'; import { fileSize } from '@/utils'; import { Omit } from 'lodash'; interface FileInputProps extends Omit, 'onChange'> { error?: string; + /** Existing image URL to show as a preview (e.g. from the server) */ + existingPhotoUrl?: string | null; onChange?: (file: File | null) => void; } -export default function FileInput({ name, error, onChange }: FileInputProps) { +export default function FileInput({ name, error, onChange, existingPhotoUrl }: FileInputProps) { const fileInput = useRef(null); const [file, setFile] = useState(null); + const [preview, setPreview] = useState(existingPhotoUrl ?? null); + + // Revoke the object URL when the component unmounts to avoid memory leaks + useEffect(() => { + return () => { + if (preview && preview.startsWith('blob:')) { + URL.revokeObjectURL(preview); + } + }; + }, [preview]); function handleBrowse() { fileInput?.current?.click(); @@ -17,17 +29,23 @@ export default function FileInput({ name, error, onChange }: FileInputProps) { function handleRemove() { setFile(null); + setPreview(existingPhotoUrl ?? null); onChange?.(null); - - // fileInput?.current?.value = ''; } function handleChange(e: React.FormEvent) { const files = e.currentTarget?.files as FileList; - const file = files[0] || null; + const selected = files[0] || null; - setFile(file); - onChange?.(file); + setFile(selected); + onChange?.(selected); + + if (selected && selected.type.startsWith('image/')) { + const objectUrl = URL.createObjectURL(selected); + setPreview(objectUrl); + } else { + setPreview(existingPhotoUrl ?? null); + } } return ( @@ -40,23 +58,39 @@ export default function FileInput({ name, error, onChange }: FileInputProps) { id={name} ref={fileInput} type="file" + accept="image/*" className="hidden" onChange={handleChange} /> + + {/* Image preview */} + {preview && ( +
+ Profile preview +
+ )} + {!file && (
- +
)} {file && (
-
+
{file?.name} ({fileSize(file?.size)})
- +
+ + +
)}
diff --git a/example/inertia-pingcrm-app/resources/js/Components/Header/BottomHeader.tsx b/example/inertia-pingcrm-app/resources/js/Components/Header/BottomHeader.tsx index 2f2de460..5766b26c 100644 --- a/example/inertia-pingcrm-app/resources/js/Components/Header/BottomHeader.tsx +++ b/example/inertia-pingcrm-app/resources/js/Components/Header/BottomHeader.tsx @@ -27,7 +27,7 @@ export default () => {
setMenuOpened(false)} > diff --git a/example/inertia-pingcrm-app/resources/js/Pages/Profile/Edit.tsx b/example/inertia-pingcrm-app/resources/js/Pages/Profile/Edit.tsx new file mode 100644 index 00000000..449227f9 --- /dev/null +++ b/example/inertia-pingcrm-app/resources/js/Pages/Profile/Edit.tsx @@ -0,0 +1,102 @@ +import LoadingButton from "@/Components/Button/LoadingButton" +import FieldGroup from "@/Components/Form/FieldGroup" +import FileInput from "@/Components/Form/FileInput" +import TextInput from "@/Components/Form/TextInput" +import MainLayout from "@/Layouts/MainLayout" +import { Head, useForm, usePage } from "@inertiajs/react" +import React from "react" + +interface ProfileUser { + id: number; + first_name: string; + last_name: string; + email: string; + photo: string | null; + password: string; +} + +const Edit = () => { + const { user } = usePage<{ user: ProfileUser }>().props + + const { data, setData, errors, post, processing } = useForm({ + first_name: user.first_name || "", + last_name: user.last_name || "", + email: user.email || "", + password: "", + photo: null as File | null, + }) + + function handleSubmit(e: React.FormEvent) { + e.preventDefault() + post("/profile", { forceFormData: true }) + } + + return ( +
+ +

My Profile

+
+
+
+ + setData("first_name", e.target.value)} + /> + + + + setData("last_name", e.target.value)} + /> + + + + setData("email", e.target.value)} + /> + + + + setData("password", e.target.value)} + /> + + + + setData("photo", file)} + /> + +
+
+ + Save Changes + +
+
+
+
+ ) +} + +Edit.layout = (page: React.ReactNode) => + +export default Edit diff --git a/example/inertia-pingcrm-app/resources/js/Pages/Users/Create.tsx b/example/inertia-pingcrm-app/resources/js/Pages/Users/Create.tsx index 707aa100..28ee7e19 100644 --- a/example/inertia-pingcrm-app/resources/js/Pages/Users/Create.tsx +++ b/example/inertia-pingcrm-app/resources/js/Pages/Users/Create.tsx @@ -13,12 +13,13 @@ const Create = () => { email: "", password: "", owner: "0", - photo: "", + photo: null as File | null, }) function handleSubmit(e: React.FormEvent) { e.preventDefault() - post(route("users.store")) + // forceFormData ensures Inertia sends multipart/form-data when a photo is attached. + post(route("users.store"), { forceFormData: true }) } return ( @@ -106,8 +107,7 @@ const Create = () => { name="photo" accept="image/*" error={errors.photo} - value={data.photo} - onChange={photo => setData("photo", photo as unknown as string)} + onChange={file => setData("photo", file)} />
diff --git a/example/inertia-pingcrm-app/resources/js/Pages/Users/Edit.tsx b/example/inertia-pingcrm-app/resources/js/Pages/Users/Edit.tsx index 50e85127..aa0b3cb7 100644 --- a/example/inertia-pingcrm-app/resources/js/Pages/Users/Edit.tsx +++ b/example/inertia-pingcrm-app/resources/js/Pages/Users/Edit.tsx @@ -22,7 +22,7 @@ const Edit = () => { email: user.email || '', password: user.password || '', owner: user.owner ? '1' : '0' || '0', - photo: '', + photo: null as File | null, // NOTE: When working with Laravel PUT/PATCH requests and FormData // you SHOULD send POST request and fake the PUT request like this. @@ -33,7 +33,8 @@ const Edit = () => { e.preventDefault(); // NOTE: We are using POST method here, not PUT/PATCH. See comment above. - post(route('users.update', user.id)); + // forceFormData ensures Inertia serialises the File as multipart/form-data. + post(route('users.update', user.id), { forceFormData: true }); } function destroy() { @@ -142,10 +143,8 @@ const Edit = () => { name="photo" accept="image/*" error={errors.photo} - value={data.photo} - onChange={photo => { - setData('photo', photo as unknown as string); - }} + existingPhotoUrl={user.photo as unknown as string} + onChange={file => setData('photo', file)} />
diff --git a/example/inertia-pingcrm-app/resources/js/app.tsx b/example/inertia-pingcrm-app/resources/js/app.tsx index 6345edc6..07ac9753 100644 --- a/example/inertia-pingcrm-app/resources/js/app.tsx +++ b/example/inertia-pingcrm-app/resources/js/app.tsx @@ -11,11 +11,16 @@ const routeMap: Record = { 'logout': '/logout', 'users': '/users', 'users.create': '/users/create', + 'users.store': '/users', 'organizations': '/organizations', 'organizations.create': '/organizations/create', + 'organizations.store': '/organizations', 'contacts': '/contacts', 'contacts.create': '/contacts/create', + 'contacts.store': '/contacts', 'reports': '/reports', + 'profile': '/profile', + 'profile.update': '/profile', }; // Basic Ziggy route() shim to handle PingCRM's URL generation @@ -37,32 +42,33 @@ function currentRouteName(): string { } window.route = function (name, params, absolute) { - let url = "/"; + let path = "/"; if (name) { if (routeMap[name]) { - url = routeMap[name]; + path = routeMap[name]; } else { let parts = name.split('.'); - url = "/" + parts[0]; + path = "/" + parts[0]; if (parts[1] === 'edit' && params) { - url += "/" + params + "/edit"; + path += "/" + params + "/edit"; } else if (parts[1] === 'destroy' && params) { - url += "/" + params; + path += "/" + params; } else if (parts[1] === 'update' && params) { - url += "/" + params; + path += "/" + params; } else if (parts[1] === 'restore' && params) { - url += "/" + params + "/restore"; + path += "/" + params + "/restore"; } else if (parts[1] === 'create') { - url += "/create"; + path += "/create"; } } } - const router = String(url); - - // Add current() method - const routeObj = new String(router) as string & { current: (pattern?: string) => string | boolean }; - (routeObj as any).current = function(pattern?: string) { + // Return a URL object so Inertia can use it directly (typeof URL === 'object', + // which is correct — Inertia's visit() handles URL instances natively). + // Using new String() was broken because typeof new String() === 'object' but + // it isn't a URL instance, so Inertia tried to read .href from it and got undefined. + const urlObj = new URL(path, window.location.href) as URL & { current: (pattern?: string) => string | boolean }; + urlObj.current = function(pattern?: string) { const pathname = window.location.pathname; // Without a pattern, return the current route name (Ziggy behaviour used by FilterBar) if (!pattern) return currentRouteName(); @@ -70,7 +76,7 @@ window.route = function (name, params, absolute) { const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$'); return regex.test(currentSegment) || (name != null && regex.test(name)); }; - return routeObj; + return urlObj as any; }; createInertiaApp({ diff --git a/example/inertia-pingcrm-app/resources/js/route.ts b/example/inertia-pingcrm-app/resources/js/route.ts new file mode 100644 index 00000000..2621fb98 --- /dev/null +++ b/example/inertia-pingcrm-app/resources/js/route.ts @@ -0,0 +1,61 @@ +const routeMap: Record = { + dashboard: '/', + login: '/login', + 'login.store': '/login', + logout: '/logout', + users: '/users', + 'users.create': '/users/create', + 'users.store': '/users', + organizations: '/organizations', + 'organizations.create': '/organizations/create', + 'organizations.store': '/organizations', + contacts: '/contacts', + 'contacts.create': '/contacts/create', + 'contacts.store': '/contacts', + reports: '/reports', +}; + +const reverseRouteMap: Record = Object.fromEntries( + Object.entries(routeMap).map(([k, v]) => [v, k]) +); + +function currentRouteName(): string { + const pathname = window.location.pathname; + if (reverseRouteMap[pathname]) return reverseRouteMap[pathname]; + const parts = pathname.replace(/^\//, '').split('/'); + if (parts.length >= 3 && parts[2] === 'edit') return `${parts[0]}.edit`; + if (parts.length >= 3 && parts[2] === 'restore') return `${parts[0]}.restore`; + if (parts.length >= 2 && parts[1] === 'create') return `${parts[0]}.create`; + if (parts.length >= 2) return `${parts[0]}.show`; + return parts[0] || 'dashboard'; +} + +interface RouteFunction { + (name?: string, params?: unknown): string; + /** Returns the current route name, or checks if it matches a glob pattern. */ + current(pattern?: string): string | boolean; +} + +const route = function (name?: string, params?: unknown): string { + if (!name) return '/'; + if (routeMap[name]) return routeMap[name]; + + const parts = name.split('.'); + let path = '/' + parts[0]; + if (parts[1] === 'edit' && params) path += `/${params}/edit`; + else if (parts[1] === 'destroy' && params) path += `/${params}`; + else if (parts[1] === 'update' && params) path += `/${params}`; + else if (parts[1] === 'restore' && params) path += `/${params}/restore`; + else if (parts[1] === 'create') path += '/create'; + return path; +} as RouteFunction; + +route.current = function (pattern?: string): string | boolean { + if (!pattern) return currentRouteName(); + const currentSegment = + window.location.pathname.replace(/^\//, '').split('/')[0] || 'dashboard'; + const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$'); + return regex.test(currentSegment); +}; + +export { route }; \ No newline at end of file diff --git a/example/inertia-pingcrm-app/routes/web.py b/example/inertia-pingcrm-app/routes/web.py index dc1717c4..1f312e05 100644 --- a/example/inertia-pingcrm-app/routes/web.py +++ b/example/inertia-pingcrm-app/routes/web.py @@ -4,6 +4,7 @@ from app.http.controllers import organizations_controller from app.http.controllers import reports_controller from app.http.controllers import users_controller +from app.http.controllers import profile_controller from app.http.controllers.auth import authenticated_session_controller from authentication.middlewares.auth import auth from fastapi import Depends @@ -15,7 +16,8 @@ guest.get("/login", authenticated_session_controller.create) guest.post("/login", authenticated_session_controller.store) guest.delete("/logout", authenticated_session_controller.destroy) -guest.get("/img/{path:path}", images_controller.show) + +guest.get("/images/{path:path}", images_controller.stream) # Protected routes — auth_required dependency applied to every route auth = Router(dependencies=[Depends(auth)]) @@ -24,3 +26,5 @@ auth.resource("organizations", organizations_controller) auth.resource("contacts", contacts_controller) auth.get("/reports", reports_controller.index) +auth.get("/profile", profile_controller.edit) +auth.post("/profile", profile_controller.update) diff --git a/example/inertia-pingcrm-app/tests/__init__.py b/example/inertia-pingcrm-app/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/example/inertia-pingcrm-app/uv.lock b/example/inertia-pingcrm-app/uv.lock index d1d2757e..25319a63 100644 --- a/example/inertia-pingcrm-app/uv.lock +++ b/example/inertia-pingcrm-app/uv.lock @@ -7,15 +7,6 @@ resolution-markers = [ "python_full_version < '3.13'", ] -[[package]] -name = "aiosqlite" -version = "0.22.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, -] - [[package]] name = "annotated-doc" version = "0.0.4" @@ -87,6 +78,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, ] +[[package]] +name = "boto3" +version = "1.43.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/a8/5422bd25bd2520a6122cb82b2dfa280c66e380102533761b96e7a10f1a4d/boto3-1.43.8.tar.gz", hash = "sha256:d1235602d715c727c1923ef4bcdb5612a20575a9a5e4f2db00d571e0ea1f85fc", size = 113144, upload-time = "2026-05-14T19:34:36.518Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/48/920c58e5b4450dd389ef3e56dca8803af093ccc0a8e04dd69a60812b7f94/boto3-1.43.8-py3-none-any.whl", hash = "sha256:1894497c383e3cdf50e210f1f57a43e9f4047a5d3accc73ffdb7eacc3b0f011b", size = 140523, upload-time = "2026-05-14T19:34:33.883Z" }, +] + +[[package]] +name = "botocore" +version = "1.43.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/bb/7c1f5d12e1fbaf88a03d504bfa2f03fa6913f127051a7b121fe3bcaadefb/botocore-1.43.8.tar.gz", hash = "sha256:611ad8b1f60661373cd39d9391ff16f1eaf8f5cb1d0a691563a4201d1a2603ce", size = 15358475, upload-time = "2026-05-14T19:34:23.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/d8/c5486e4f0c6790f830368a171017d0687d89ffd3a57511bba533b56ee50f/botocore-1.43.8-py3-none-any.whl", hash = "sha256:6257d2655c3abe75eaa49e218b7d883cdc7cea64652b451e5feb08a6c169da3c", size = 15038825, upload-time = "2026-05-14T19:34:18.877Z" }, +] + [[package]] name = "certifi" version = "2026.2.25" @@ -341,10 +360,9 @@ wheels = [ [[package]] name = "fastapi-startkit" -version = "0.13.6" +version = "0.20.0" source = { editable = "../../fastapi_startkit" } dependencies = [ - { name = "aiosqlite" }, { name = "cleo" }, { name = "dotenv" }, { name = "dotty-dict" }, @@ -361,6 +379,7 @@ database = [ ] fastapi = [ { name = "fastapi", extra = ["standard"] }, + { name = "itsdangerous" }, ] postgres = [ { name = "asyncpg" }, @@ -369,7 +388,6 @@ postgres = [ [package.metadata] requires-dist = [ { name = "aiomysql", marker = "extra == 'mysql'", specifier = ">=0.2.0" }, - { name = "aiosqlite", specifier = ">=0.22.1" }, { name = "aiosqlite", marker = "extra == 'sqlite'", specifier = ">=0.22.1" }, { name = "asyncpg", marker = "extra == 'postgres'", specifier = ">=0.29.0" }, { name = "cleo", specifier = ">=2.1.0,<3.0.0" }, @@ -378,17 +396,21 @@ requires-dist = [ { name = "faker", marker = "extra == 'database'", specifier = ">=40.13.0" }, { name = "fastapi", extras = ["standard"], marker = "extra == 'fastapi'", specifier = ">=0.124.4,<0.125.0" }, { name = "inflection", specifier = ">=0.5.1" }, + { name = "itsdangerous", marker = "extra == 'fastapi'", specifier = ">=2.2.0" }, + { name = "jinja2", marker = "extra == 'inertia'", specifier = ">=3.1" }, { name = "jinja2", marker = "extra == 'vite'", specifier = ">=3.1" }, + { name = "markupsafe", marker = "extra == 'inertia'", specifier = ">=2.0" }, { name = "pendulum", specifier = ">=3.1.0,<4.0.0" }, { name = "pydantic", specifier = ">=2.12.5" }, { name = "requests", specifier = ">=2.32.5,<3.0.0" }, { name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'database'", specifier = ">=2.0.38" }, ] -provides-extras = ["fastapi", "database", "sqlite", "postgres", "mysql", "vite"] +provides-extras = ["fastapi", "database", "sqlite", "postgres", "mysql", "vite", "inertia"] [package.metadata.requires-dev] dev = [ { name = "dumpdie", specifier = ">=1.5.0" }, + { name = "itsdangerous", specifier = ">=2.2.0" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, { name = "ruff", specifier = ">=0.9.0" }, @@ -594,27 +616,39 @@ name = "inertia-example" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "boto3" }, { name = "faker" }, { name = "fastapi-startkit", extra = ["database", "fastapi", "postgres"] }, { name = "itsdangerous" }, { name = "jinja2" }, + { name = "python-multipart" }, ] [package.dev-dependencies] dev = [ { name = "dumpdie" }, + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, ] [package.metadata] requires-dist = [ + { name = "boto3", specifier = ">=1.35.0" }, { name = "faker", specifier = ">=40.15.0" }, { name = "fastapi-startkit", extras = ["fastapi", "database", "postgres"], editable = "../../fastapi_startkit" }, { name = "itsdangerous", specifier = ">=2.2.0" }, { name = "jinja2", specifier = ">=3.1" }, + { name = "python-multipart", specifier = ">=0.0.9" }, ] [package.metadata.requires-dev] -dev = [{ name = "dumpdie", specifier = ">=1.5.0" }] +dev = [ + { name = "dumpdie", specifier = ">=1.5.0" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "pytest", specifier = ">=8.0" }, + { name = "pytest-asyncio", specifier = ">=0.24" }, +] [[package]] name = "inflection" @@ -625,6 +659,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/59/91/aa6bde563e0085a02a435aa99b49ef75b0a4b062635e606dab23ce18d720/inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2", size = 9454, upload-time = "2020-08-22T08:16:27.816Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "itsdangerous" version = "2.2.0" @@ -646,6 +689,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -730,6 +782,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + [[package]] name = "pendulum" version = "3.2.0" @@ -773,6 +834,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/fb/d65db067a67df7252f18b0cb7420dda84078b9e8bfb375215469c14a50be/pendulum-3.2.0-py3-none-any.whl", hash = "sha256:f3a9c18a89b4d9ef39c5fa6a78722aaff8d5be2597c129a3b16b9f40a561acf3", size = 114111, upload-time = "2026-01-30T11:22:22.361Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pydantic" version = "2.13.3" @@ -877,6 +947,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1126,6 +1225,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/62/b88e5879512c55b8ee979c666ee6902adc4ed05007226de266410ae27965/rignore-0.7.6-cp314-cp314t-win_arm64.whl", hash = "sha256:b83adabeb3e8cf662cabe1931b83e165b88c526fa6af6b3aa90429686e474896", size = 656035, upload-time = "2025-11-05T21:41:31.13Z" }, ] +[[package]] +name = "s3transfer" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/ec/7c692cde9125b77e84b307354d4fb705f98b8ccad59a036d5957ca75bfc3/s3transfer-0.17.0.tar.gz", hash = "sha256:9edeb6d1c3c2f89d6050348548834ad8289610d886e5bf7b7207728bd43ce33a", size = 155337, upload-time = "2026-04-29T22:07:36.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/72/c6c32d2b657fa3dad1de340254e14390b1e334ce38268b7ad51abda3c8c2/s3transfer-0.17.0-py3-none-any.whl", hash = "sha256:ce3801712acf4ad3e89fb9990df97b4972e93f4b3b0004d214be5bce12814c20", size = 86811, upload-time = "2026-04-29T22:07:34.966Z" }, +] + [[package]] name = "sentry-sdk" version = "2.58.0" diff --git a/fastapi_startkit/pyproject.toml b/fastapi_startkit/pyproject.toml index 298dd5be..6ce38364 100644 --- a/fastapi_startkit/pyproject.toml +++ b/fastapi_startkit/pyproject.toml @@ -42,6 +42,11 @@ vite=[ "jinja2>=3.1", ] +inertia = [ + "jinja2>=3.1", + "markupsafe>=2.0", +] + [dependency-groups] dev = [ "dumpdie>=1.5.0", diff --git a/fastapi_startkit/src/fastapi_startkit/application.py b/fastapi_startkit/src/fastapi_startkit/application.py index 3701052a..1c0d8a7a 100644 --- a/fastapi_startkit/src/fastapi_startkit/application.py +++ b/fastapi_startkit/src/fastapi_startkit/application.py @@ -112,6 +112,12 @@ def use_fastapi(self, fastapi: "FastAPI"): def use_base_path(self, path: str): return self.base_path / path + def storage_path(self, path: str = "") -> str: + return str(self.base_path / "storage" / path) + + def public_path(self, path: str = "") -> str: + return str(self.base_path / "public" / path) + def get(self, path: str, **kwargs) -> Callable: return self.fastapi.get(path, **kwargs) diff --git a/fastapi_startkit/src/fastapi_startkit/exceptions/__init__.py b/fastapi_startkit/src/fastapi_startkit/exceptions/__init__.py index b39dec8a..a8dc7b24 100644 --- a/fastapi_startkit/src/fastapi_startkit/exceptions/__init__.py +++ b/fastapi_startkit/src/fastapi_startkit/exceptions/__init__.py @@ -27,6 +27,7 @@ InvalidPackageName, LoaderNotFound, QueueException, + ValidationException, AmbiguousError, MethodNotAllowedException, ModelNotFoundException, diff --git a/fastapi_startkit/src/fastapi_startkit/exceptions/exceptions.py b/fastapi_startkit/src/fastapi_startkit/exceptions/exceptions.py index cc3f6280..378024fa 100644 --- a/fastapi_startkit/src/fastapi_startkit/exceptions/exceptions.py +++ b/fastapi_startkit/src/fastapi_startkit/exceptions/exceptions.py @@ -216,3 +216,9 @@ class InvalidPackageName(Exception): class LoaderNotFound(Exception): pass + + +class ValidationException(Exception): + def __init__(self, errors: dict): + super().__init__("The given data was invalid.") + self.errors = errors diff --git a/fastapi_startkit/src/fastapi_startkit/facades/Storage.py b/fastapi_startkit/src/fastapi_startkit/facades/Storage.py deleted file mode 100644 index 780bea08..00000000 --- a/fastapi_startkit/src/fastapi_startkit/facades/Storage.py +++ /dev/null @@ -1,5 +0,0 @@ -from .Facade import Facade - - -class Storage(metaclass=Facade): - key = "storage" diff --git a/fastapi_startkit/src/fastapi_startkit/facades/Storage.pyi b/fastapi_startkit/src/fastapi_startkit/facades/Storage.pyi deleted file mode 100644 index e0ccc2f1..00000000 --- a/fastapi_startkit/src/fastapi_startkit/facades/Storage.pyi +++ /dev/null @@ -1,12 +0,0 @@ -from typing import Any - -class Storage: - """File storage facade.""" - - def add_driver(name: str, driver: str): ... - def set_configuration(config: dict) -> "Storage": ... - def get_driver(name: str = None) -> Any: ... - def get_config_options(name: str = None) -> dict: ... - def disk(name: str = "default") -> Any: - """Get the file manager instance for the given disk name.""" - ... diff --git a/fastapi_startkit/src/fastapi_startkit/facades/__init__.py b/fastapi_startkit/src/fastapi_startkit/facades/__init__.py index de7ed3a8..da562fb2 100644 --- a/fastapi_startkit/src/fastapi_startkit/facades/__init__.py +++ b/fastapi_startkit/src/fastapi_startkit/facades/__init__.py @@ -11,7 +11,6 @@ from .Config import Config from .Loader import Loader from .Notification import Notification -from .Storage import Storage from .Dump import Dump from .Queue import Queue from .Cache import Cache diff --git a/fastapi_startkit/src/fastapi_startkit/fastapi/__init__.py b/fastapi_startkit/src/fastapi_startkit/fastapi/__init__.py index 185a8934..b19ad15c 100644 --- a/fastapi_startkit/src/fastapi_startkit/fastapi/__init__.py +++ b/fastapi_startkit/src/fastapi_startkit/fastapi/__init__.py @@ -1,4 +1,5 @@ from .providers.fastapi_provider import FastAPIProvider from .routers.router import Router +from .requests.model import RequestModel -__all__ = ["FastAPIProvider", "Router"] +__all__ = ["FastAPIProvider", "Router", "RequestModel"] diff --git a/fastapi_startkit/src/fastapi_startkit/fastapi/exceptions.py b/fastapi_startkit/src/fastapi_startkit/fastapi/exceptions.py index 1bce844a..0a5cd54b 100644 --- a/fastapi_startkit/src/fastapi_startkit/fastapi/exceptions.py +++ b/fastapi_startkit/src/fastapi_startkit/fastapi/exceptions.py @@ -26,3 +26,45 @@ async def render(self, request, exc): content = {"message": "Server Error"} return JSONResponse(status_code=500, content=content) + + +class ValidationExceptionHandler: + """ + Handles RequestValidationError with content negotiation. + + JSON requests (API clients) receive a 422 Unprocessable Entity response. + Non-JSON requests (browser/Inertia) have errors flashed to the session + and are redirected back to the referring page. + """ + + def report(self, exc) -> None: + pass + + async def render(self, request, exc): + accept = request.headers.get("accept", "") + content_type = request.headers.get("content-type", "") + + wants_json = ( + "application/json" in accept + or content_type.startswith("application/json") + ) + + errors = {} + for err in exc.errors(): + field = ".".join(str(x) for x in err["loc"][1:]) + errors.setdefault(field, []).append(err["msg"]) + + if wants_json: + from fastapi.responses import JSONResponse + + return JSONResponse(status_code=422, content={"errors": errors}) + + if "session" in request.scope: + request.session["errors"] = errors + + from starlette.responses import RedirectResponse + + return RedirectResponse( + url=request.headers.get("referer", "/"), + status_code=303, + ) diff --git a/fastapi_startkit/src/fastapi_startkit/fastapi/providers/fastapi_provider.py b/fastapi_startkit/src/fastapi_startkit/fastapi/providers/fastapi_provider.py index bd4d72a5..2f062e06 100644 --- a/fastapi_startkit/src/fastapi_startkit/fastapi/providers/fastapi_provider.py +++ b/fastapi_startkit/src/fastapi_startkit/fastapi/providers/fastapi_provider.py @@ -1,4 +1,4 @@ -from fastapi_startkit.fastapi.exceptions import HTTPExceptionHandler +from fastapi_startkit.fastapi.exceptions import HTTPExceptionHandler, ValidationExceptionHandler from fastapi import FastAPI from fastapi_startkit.fastapi.commands import ServeCommand @@ -21,10 +21,19 @@ def boot(self): def _register_exception_handlers(self): """Wire exception_manager as a catch-all handler for all exceptions.""" + from fastapi import HTTPException + from fastapi.exceptions import RequestValidationError + exception_manager = self.app.exception_manager exception_manager.register_handler(Exception, HTTPExceptionHandler()) + exception_manager.register_handler(HTTPException, HTTPExceptionHandler()) + exception_manager.register_handler(RequestValidationError, ValidationExceptionHandler()) async def handler(request, exc): return await exception_manager.handle(exc, {"request": request}) + # FastAPI registers its own handlers for these two types internally, + # so they must be overridden explicitly + self.app.fastapi.add_exception_handler(HTTPException, handler) + self.app.fastapi.add_exception_handler(RequestValidationError, handler) self.app.fastapi.add_exception_handler(Exception, handler) diff --git a/fastapi_startkit/src/fastapi_startkit/fastapi/requests/model.py b/fastapi_startkit/src/fastapi_startkit/fastapi/requests/model.py new file mode 100644 index 00000000..e8546634 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/fastapi/requests/model.py @@ -0,0 +1,34 @@ +import inspect + +from fastapi import Form +from fastapi.params import Query as QueryParam +from pydantic import BaseModel + + +class RequestModel(BaseModel): + @classmethod + def __pydantic_init_subclass__(cls, **kwargs): + super().__pydantic_init_subclass__(**kwargs) + + params = [] + for name, field in cls.model_fields.items(): + if isinstance(field, QueryParam): + default = field + elif field.is_required(): + default = Form(...) + else: + default = Form(default=field.default) + + params.append( + inspect.Parameter( + name, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + default=default, + annotation=field.annotation, + ) + ) + + cls.__signature__ = inspect.Signature(params) + + def validated(self) -> dict: + return {k: v for k, v in self.model_dump().items() if v} diff --git a/fastapi_startkit/src/fastapi_startkit/helpers/app.py b/fastapi_startkit/src/fastapi_startkit/helpers/app.py new file mode 100644 index 00000000..f4c6c455 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/helpers/app.py @@ -0,0 +1,9 @@ +def storage_path(path: str = "") -> str: + """Get the path to the storage directory.""" + from fastapi_startkit.application import app + return app().storage_path(path) + +def public_path(path: str = "") -> str: + """Get the path to the public directory.""" + from fastapi_startkit.application import app + return app().public_path(path) diff --git a/fastapi_startkit/src/fastapi_startkit/inertia/middleware.py b/fastapi_startkit/src/fastapi_startkit/inertia/middleware.py index b33aa057..52e9bb8d 100644 --- a/fastapi_startkit/src/fastapi_startkit/inertia/middleware.py +++ b/fastapi_startkit/src/fastapi_startkit/inertia/middleware.py @@ -94,7 +94,7 @@ def on_redirect_with_fragment(request: Request, response: Response) -> Response: def resolve_validation_errors(request: Request) -> dict: if "session" not in request.scope: return {} - return request.session.get("errors", {}) + return request.session.pop("errors", {}) @staticmethod def reflash(request: Request) -> None: diff --git a/fastapi_startkit/src/fastapi_startkit/inertia/provider.py b/fastapi_startkit/src/fastapi_startkit/inertia/provider.py index 0dc2c870..6c10332e 100644 --- a/fastapi_startkit/src/fastapi_startkit/inertia/provider.py +++ b/fastapi_startkit/src/fastapi_startkit/inertia/provider.py @@ -1,4 +1,5 @@ import json +from markupsafe import Markup from fastapi_startkit.providers import Provider from .inertia import Inertia from .middleware import InertiaMiddleware @@ -8,33 +9,20 @@ class InertiaProvider(Provider): provider_key = "inertia" def register(self) -> None: - """Bind the Inertia class to the container.""" self.app.bind("inertia", Inertia) def boot(self) -> None: - """Configure template globals and middleware.""" - # 1. Register Middleware - # We add it to the FastAPI instance via the application helper self.app.add_middleware(InertiaMiddleware) - # 2. Register Template Globals if self.app.has("templates"): templates = self.app.make("templates") - try: - from markupsafe import Markup - except ImportError: - Markup = str - def inertia_helper(page): - """Jinja2 helper to render the root div for Inertia.""" encoded_page = json.dumps(page) - # Ensure single quotes are used for the attribute to avoid conflict with JSON double quotes return Markup( f'
' ) templates.env.globals["inertia"] = inertia_helper - - # Also share the inertia instance itself if needed templates.env.globals["Inertia"] = self.app.make("inertia") + diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/__init__.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/__init__.py index 0cc61349..18a6579e 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/__init__.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/__init__.py @@ -1,3 +1,6 @@ -from .providers import DatabaseProvider -from .config.config import PostgresConfig, MySQLConfig, SQLiteConfig +from .config.config import MySQLConfig, PostgresConfig, SQLiteConfig +from .facades import DB from .models import Model +from .providers import DatabaseProvider + +__all__ = ["DatabaseProvider", "PostgresConfig", "MySQLConfig", "SQLiteConfig", "Model", "DB"] diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/connection.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/connection.py index 2ba28792..4ed89f9d 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/connection.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/connection.py @@ -1,8 +1,9 @@ from typing import List -from fastapi_startkit.masoniteorm.models.builder import QueryBuilder from sqlalchemy import text -from sqlalchemy.ext.asyncio import AsyncEngine, AsyncConnection, AsyncTransaction +from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, AsyncTransaction + +from fastapi_startkit.masoniteorm.models.builder import QueryBuilder class Connection: @@ -83,8 +84,12 @@ async def run(self, query: str, bindings: list | None = None): query, bindings = self.sql_alchemy_bindings(query, bindings) conn = await self.get_connection() + result = await conn.execute(text(query), bindings or {}) - return await conn.execute(text(query), bindings or {}) + if not self.transactions: + await conn.commit() + + return result async def execute(self, query: str, bindings: list | None = None): query, bindings = self.sql_alchemy_bindings(query, bindings) @@ -117,13 +122,16 @@ async def delete(self, query: str, bindings: list | None = None) -> int: async def select(self, query: str, bindings: list | None = None) -> list[dict]: result = await self.run(query, bindings) - keys = result.keys() - return [dict(zip(keys, row)) for row in result.fetchall()] + + return result.mappings().all() async def select_one(self, query: str, bindings: list | None = None) -> dict | None: result = await self.run(query, bindings) row = result.fetchone() - return dict(zip(result.keys(), row)) if row else None + result_dict = dict(zip(result.keys(), row)) if row else None + if not self.transactions and self.connection is not None: + await self.connection.commit() + return result_dict async def statement(self, query: str, bindings: list | None = None) -> bool: query, bindings = self.sql_alchemy_bindings(query, bindings) diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/facades/DB.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/facades/DB.py index a987d7aa..902a1b91 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/facades/DB.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/facades/DB.py @@ -3,9 +3,9 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from fastapi_startkit.orm.connections.connection import Connection - from fastapi_startkit.orm.connections.manager import DatabaseManager - from fastapi_startkit.orm.models.builder import QueryBuilder + from fastapi_startkit.masoniteorm.connections.connection import Connection + from fastapi_startkit.masoniteorm.connections.manager import DatabaseManager + from fastapi_startkit.masoniteorm.models.builder import QueryBuilder class DB: @@ -52,3 +52,15 @@ async def delete(cls, query: str, bindings: list | None = None) -> int: @classmethod async def statement(cls, query: str, bindings: list | None = None) -> bool: return await cls.instance().connection(None).statement(query, bindings) + + @classmethod + async def begin_transaction(cls, name: str | None = None) -> None: + await cls.instance().connection(name).begin_transaction() + + @classmethod + async def commit(cls, name: str | None = None) -> None: + await cls.instance().connection(name).commit_transaction() + + @classmethod + async def rollback(cls, name: str | None = None) -> None: + await cls.instance().connection(name).rollback() diff --git a/fastapi_startkit/src/fastapi_startkit/storage/__init__.py b/fastapi_startkit/src/fastapi_startkit/storage/__init__.py new file mode 100644 index 00000000..dc72a75d --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/storage/__init__.py @@ -0,0 +1,3 @@ +from .storage import Storage +from .config import S3Config, LocalDiskConfig, PublicDiskConfig +from .drivers.fake import FakeDriver diff --git a/fastapi_startkit/src/fastapi_startkit/storage/config/__init__.py b/fastapi_startkit/src/fastapi_startkit/storage/config/__init__.py new file mode 100644 index 00000000..703689df --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/storage/config/__init__.py @@ -0,0 +1,33 @@ +from dataclasses import dataclass, field + + +@dataclass +class LocalDiskConfig: + driver: str = field(default="local") + root: str = "storage" + serve: bool = True + throw: bool = False + report: bool = False + + +@dataclass +class PublicDiskConfig(LocalDiskConfig): + driver: str = field(default="local") + serve: bool = True + throw: bool = False + report: bool = False + visibility: str = "public" + url: str = "/storage" + +@dataclass +class S3Config: + driver: str = field(default="s3") + key: str = "" + secret: str = "" + region: str = "" + bucket: str = "" + url: str = "" + endpoint: str = "" + use_path_style_endpoint: bool = False + throw: bool = False + report: bool = False diff --git a/fastapi_startkit/src/fastapi_startkit/storage/config/storage.py b/fastapi_startkit/src/fastapi_startkit/storage/config/storage.py new file mode 100644 index 00000000..a1801f2b --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/storage/config/storage.py @@ -0,0 +1,30 @@ +from dataclasses import dataclass, field +from typing import Any, Dict + +from fastapi_startkit.environment import env +from fastapi_startkit.storage import LocalDiskConfig, S3Config, PublicDiskConfig + + +@dataclass +class StorageConfig: + default: str = field(default_factory=lambda: env("FILESYSTEM_DISK", "local")) + + disks: dict[str, Dict[str, Any]] = field( + default_factory=lambda: { + "local": LocalDiskConfig( + root=env("FILESYSTEM_DISK_ROOT", "storage"), + ), + "public": PublicDiskConfig( + root=env("FILESYSTEM_PUBLIC_DISK_ROOT", "storage/app/public"), + url=env("FILESYSTEM_PUBLIC_DISK_URL", "/storage"), + ), + "s3": S3Config( + key=env("AWS_ACCESS_KEY_ID"), + secret=env("AWS_SECRET_ACCESS_KEY"), + region=env("AWS_DEFAULT_REGION"), + bucket=env("AWS_BUCKET"), + url=env("AWS_URL"), + endpoint=env("AWS_ENDPOINT"), + ), + } + ) diff --git a/fastapi_startkit/src/fastapi_startkit/storage/drivers/__init__.py b/fastapi_startkit/src/fastapi_startkit/storage/drivers/__init__.py new file mode 100644 index 00000000..99d682f6 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/storage/drivers/__init__.py @@ -0,0 +1,3 @@ +from .local import LocalDriver +from .s3 import S3Driver +from .fake import FakeDriver \ No newline at end of file diff --git a/fastapi_startkit/src/fastapi_startkit/storage/drivers/fake.py b/fastapi_startkit/src/fastapi_startkit/storage/drivers/fake.py new file mode 100644 index 00000000..cc2c2a0e --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/storage/drivers/fake.py @@ -0,0 +1,69 @@ +import shutil +import tempfile + +from .local import LocalDriver + + +class FakeDriver(LocalDriver): + def __init__(self, application, disk_name: str = "default"): + super().__init__(application) + self._disk_name = disk_name + self._root = tempfile.mkdtemp(prefix=f"storage_fake_{disk_name}_") + self.options = {"root": self._root} + + def set_options(self, options): + # Ignore — never overwrite the fake temp root with real disk config. + return self + + def __enter__(self): + return self + + def __exit__(self, *_): + self.cleanup() + + def cleanup(self): + shutil.rmtree(self._root, ignore_errors=True) + + def assert_exists(self, paths, content=None): + if isinstance(paths, str): + paths = [paths] + + for path in paths: + assert self.exists(path), ( + f"Storage::fake({self._disk_name!r}): " + f"failed asserting that [{path!r}] exists." + ) + if content is not None: + actual = self.get(path) + assert actual == content, ( + f"Storage::fake({self._disk_name!r}): " + f"content of [{path!r}] does not match.\n" + f" expected: {content!r}\n" + f" actual: {actual!r}" + ) + + return self + + def assert_missing(self, paths): + if isinstance(paths, str): + paths = [paths] + + for path in paths: + assert self.missing(path), ( + f"Storage::fake({self._disk_name!r}): " + f"failed asserting that [{path!r}] is missing." + ) + + return self + + def assert_count(self, count: int, directory: str = ""): + files = self.get_files(directory) + assert len(files) == count, ( + f"Storage::fake({self._disk_name!r}): " + f"expected {count} file(s) in [{directory or '/'}], " + f"found {len(files)}: {[f.name for f in files]}" + ) + return self + + def assert_directory_empty(self, directory: str = ""): + return self.assert_count(0, directory) diff --git a/fastapi_startkit/src/fastapi_startkit/storage/drivers/local.py b/fastapi_startkit/src/fastapi_startkit/storage/drivers/local.py new file mode 100644 index 00000000..29be5ff9 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/storage/drivers/local.py @@ -0,0 +1,133 @@ +import os +import uuid +from os.path import isfile, join +from shutil import copyfile, move + +from ..filestream import FileStream +from ..file import File +from ...utils.filesystem import get_extension + + +class LocalDriver: + def __init__(self, application): + self.application = application + self.options = {} + + def set_options(self, options): + self.options = options + return self + + def get_path(self, path): + root = self.options.get("root") or self.options.get("path") + if not os.path.isabs(root): + root = os.path.join(str(self.application.base_path), root) + file_path = os.path.join(root, path) + self.make_file_path_if_not_exists(file_path) + return file_path + + def get_name(self, path, alias): + extension = get_extension(path) + return f"{alias}{extension}" + + def put(self, file_path, content): + if isinstance(content, (bytes, bytearray)): + write_mode = "wb" + else: + write_mode = "w" + with open(self.get_path(os.path.join(file_path)), write_mode) as f: + f.write(content) + return content + + def put_file(self, file_path, content, name=None): + file_name = self.get_name(content.name, name or str(uuid.uuid4())) + + if hasattr(content, "get_content"): + content = content.get_content() + + if isinstance(content, str): + content = bytes(content, "utf-8") + + with open(self.get_path(os.path.join(file_path, file_name)), "wb") as f: + f.write(content) + + return os.path.join(file_path, file_name) + + def get(self, file_path): + try: + with open(self.get_path(file_path), "r") as f: + content = f.read() + + return content + except FileNotFoundError: + return None + + def exists(self, file_path): + return os.path.exists(self.get_path(file_path)) + + def missing(self, file_path): + return not self.exists(file_path) + + def stream(self, file_path): + with open(self.get_path(file_path), "r") as f: + content = f + return FileStream(content) + + def copy(self, from_file_path, to_file_path): + return copyfile(from_file_path, to_file_path) + + def move(self, from_file_path, to_file_path): + return move(self.get_path(from_file_path), self.get_path(to_file_path)) + + def prepend(self, file_path, content): + value = self.get(file_path) + content = content + value + self.put(file_path, content) + return content + + def append(self, file_path, content): + with open(self.get_path(file_path), "a") as f: + f.write(content) + return content + + def delete(self, file_path): + return os.remove(self.get_path(file_path)) + + def make_directory(self, directory): + pass + + def store(self, file, name=None): + if name: + name = f"{name}{file.extension()}" + full_path = self.get_path(name or file.hash_path_name()) + with open(full_path, "wb") as f: + f.write(file.stream()) + + return full_path + + def make_file_path_if_not_exists(self, file_path): + if not os.path.isfile(file_path): + if not os.path.exists(os.path.dirname(file_path)): + # Create the path to the model if it does not exist + os.makedirs(os.path.dirname(file_path)) + + return True + + return False + + def get_files(self, directory=""): + file_path = self.get_path(directory) + files = [] + for f in os.listdir(file_path): + if not isfile(join(file_path, f)): + continue + + files.append(File(self.get(f), f)) + + return files + + def download(self, file_path, name=None, force=False): + from fastapi.responses import FileResponse + return FileResponse( + self.get_path(file_path), + filename=name or os.path.basename(file_path) + ) diff --git a/fastapi_startkit/src/fastapi_startkit/storage/drivers/s3.py b/fastapi_startkit/src/fastapi_startkit/storage/drivers/s3.py new file mode 100644 index 00000000..c52cdf34 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/storage/drivers/s3.py @@ -0,0 +1,203 @@ +import os +import uuid + +from ..file import File +from ...utils.filesystem import get_extension + + +class S3Driver: + def __init__(self, application): + self.application = application + self.options = {} + self.connection = None + + def set_options(self, options): + self.options = options + return self + + def get_connection(self): + try: + import boto3 + except ImportError: + raise ModuleNotFoundError( + "Could not find the 'boto3' library. Run 'pip install boto3' to fix this." + ) + + if not self.connection: + self.connection = boto3.Session( + aws_access_key_id=self.options.get("key") or self.options.get("client"), + aws_secret_access_key=self.options.get("secret"), + region_name=self.options.get("region"), + ) + + return self.connection + + def get_client(self): + import botocore.config + config = botocore.config.Config( + s3={'addressing_style': 'path' if self.options.get("use_path_style_endpoint") else 'auto'} + ) + return self.get_connection().client( + "s3", + endpoint_url=self.options.get("endpoint"), + config=config + ) + + def get_resource(self): + import botocore.config + config = botocore.config.Config( + s3={'addressing_style': 'path' if self.options.get("use_path_style_endpoint") else 'auto'} + ) + return self.get_connection().resource( + "s3", + endpoint_url=self.options.get("endpoint"), + config=config + ) + + def get_bucket(self): + return self.options.get("bucket") + + def get_name(self, path, alias): + extension = get_extension(path) + return f"{alias}{extension}" + + def put(self, file_path, content): + self.get_resource().Bucket(self.get_bucket()).put_object( + Key=file_path, Body=content + ) + return content + + def put_file(self, file_path, content, name=None): + file_name = self.get_name(content.name, name or str(uuid.uuid4())) + + if hasattr(content, "get_content"): + content = content.get_content() + + self.get_resource().Bucket(self.get_bucket()).put_object( + Key=os.path.join(file_path, file_name), Body=content + ) + return os.path.join(file_path, file_name) + + def get(self, file_path): + try: + return ( + self.get_resource() + .Bucket(self.get_bucket()) + .Object(file_path) + .get() + .get("Body") + .read() + .decode("utf-8") + ) + except self.missing_file_exceptions(): + pass + + def missing_file_exceptions(self): + import botocore + + return (botocore.exceptions.ClientError,) + + def exists(self, file_path): + try: + self.get_resource().Bucket(self.get_bucket()).Object( + file_path + ).load() + return True + except self.missing_file_exceptions(): + return False + + def missing(self, file_path): + return not self.exists(file_path) + + def stream(self, file_path): + import mimetypes + from fastapi import HTTPException + from fastapi.responses import StreamingResponse + + try: + obj = self.get_client().get_object( + Bucket=self.get_bucket(), + Key=file_path, + ) + except self.missing_file_exceptions(): + raise HTTPException(status_code=404, detail="File not found.") + + media_type, _ = mimetypes.guess_type(file_path) + # No Content-Length header — avoids the BaseHTTPMiddleware streaming + # conflict that occurs when Content-Length is set on a streamed response. + return StreamingResponse( + obj["Body"].iter_chunks(chunk_size=65536), + media_type=media_type or "application/octet-stream", + ) + + def copy(self, from_file_path, to_file_path): + copy_source = {"Bucket": self.get_bucket(), "Key": from_file_path} + self.get_resource().meta.client.copy( + copy_source, self.get_bucket(), to_file_path + ) + + def move(self, from_file_path, to_file_path): + self.copy(from_file_path, to_file_path) + self.delete(from_file_path) + + def prepend(self, file_path, content): + value = self.get(file_path) + content = content + value + self.put(file_path, content) + return content + + def append(self, file_path, content): + value = self.get(file_path) or "" + value += content + self.put(file_path, content) + + def delete(self, file_path): + return ( + self.get_resource() + .Object(self.get_bucket(), file_path) + .delete() + ) + + def store(self, file, name=None): + full_path = name or file.hash_path_name() + self.get_resource().Bucket(self.get_bucket()).put_object( + Key=full_path, Body=file.stream() + ) + return full_path + + def make_file_path_if_not_exists(self, file_path): + if not os.path.isfile(file_path): + if not os.path.exists(os.path.dirname(file_path)): + # Create the path to the model if it does not exist + os.makedirs(os.path.dirname(file_path)) + + return True + + return False + + def get_files(self, directory=None): + bucket = self.get_resource().Bucket(self.get_bucket()) + + if directory: + objects = bucket.objects.all().filter(Prefix=directory) + else: + objects = bucket.objects.all() + + files = [] + for my_bucket_object in objects.all(): + if "/" not in my_bucket_object.key: + files.append(File(my_bucket_object, my_bucket_object.key)) + + return files + + def download(self, file_path, name=None, force=False): + url = self.get_client().generate_presigned_url( + "get_object", + Params={"Bucket": self.get_bucket(), "Key": file_path}, + ExpiresIn=3600, + ) + from fastapi.responses import RedirectResponse + return RedirectResponse(url) + + def url(self, file_path): + return f"{self.options.get('url')}/{file_path}" diff --git a/fastapi_startkit/src/fastapi_startkit/storage/file.py b/fastapi_startkit/src/fastapi_startkit/storage/file.py new file mode 100644 index 00000000..6f506594 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/storage/file.py @@ -0,0 +1,30 @@ +import hashlib + +from ..utils.filesystem import get_extension + + +class File: + def __init__(self, content, filename=None): + self.content = content + self.filename = filename + + def path(self): + pass + + def extension(self): + return get_extension(self.filename) + + def name(self): + return self.filename + + def stream(self): + return self.content + + def hash_path_name(self): + return f"{self.hash_name()}{self.extension()}" + + def hash_name(self): + return hashlib.sha1(bytes(self.name(), "utf-8")).hexdigest() + + def __repr__(self): + return f"{self.__class__.__name__}(name={self.name()})" diff --git a/fastapi_startkit/src/fastapi_startkit/storage/filestream.py b/fastapi_startkit/src/fastapi_startkit/storage/filestream.py new file mode 100644 index 00000000..e0e8d708 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/storage/filestream.py @@ -0,0 +1,18 @@ +import os + +from ..utils.filesystem import get_extension + + +class FileStream: + def __init__(self, stream, name=None): + self.stream = stream + self._name = name + + def path(self): + return self.stream.name + + def extension(self): + return get_extension(self._name or self.path()) + + def name(self): + return self._name or os.path.basename(self.path()) diff --git a/fastapi_startkit/src/fastapi_startkit/storage/providers/provider.py b/fastapi_startkit/src/fastapi_startkit/storage/providers/provider.py new file mode 100644 index 00000000..d34cebb8 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/storage/providers/provider.py @@ -0,0 +1,59 @@ +from pathlib import Path +from ...providers import Provider +from ..storage import StorageManager +from ...configuration import config +from ..drivers import LocalDriver, S3Driver +from ..config.storage import StorageConfig + + +class StorageProvider(Provider): + def register(self): + config_data = self.resolve_config(StorageConfig) + self.merge_config_from(config_data, "storage") + + storage = StorageManager(self.app, config("storage")) + storage.add_driver("local", LocalDriver(self.app)) + storage.add_driver("s3", S3Driver(self.app)) + self.app.bind("storage", storage) + + def boot(self): + self.publishes( + { + Path(__file__) + .resolve() + .parent.parent.joinpath("config/storage.py"): "config/storage.py" + } + ) + + if not self.app.fastapi: + return + + # Resolve the public disk root from config so the route and the driver + # always agree on the same absolute directory. + public_disk = config("storage.disks.public") or {} + public_root = public_disk.get("root", "storage/app/public") + public_dir = Path(self.app.base_path) / public_root + public_dir.mkdir(parents=True, exist_ok=True) + + # Serve public storage files via a plain bytes Response. + # FileResponse / StaticFiles are streaming responses — Starlette's + # BaseHTTPMiddleware sends an extra empty body chunk after them, which + # breaks the Content-Length and raises RuntimeError. Reading the file + # into memory and returning a regular Response avoids that entirely. + import mimetypes + from fastapi import HTTPException + from fastapi.responses import Response + + async def serve_storage_file(path: str): + file_path = public_dir / path + if not file_path.exists() or not file_path.is_file(): + raise HTTPException(status_code=404) + media_type, _ = mimetypes.guess_type(str(file_path)) + return Response( + content=file_path.read_bytes(), + media_type=media_type or "application/octet-stream", + ) + + self.app.fastapi.get("/storage/{path:path}", include_in_schema=False)( + serve_storage_file + ) diff --git a/fastapi_startkit/src/fastapi_startkit/storage/storage.py b/fastapi_startkit/src/fastapi_startkit/storage/storage.py new file mode 100644 index 00000000..07e9b0c8 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/storage/storage.py @@ -0,0 +1,185 @@ +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from fastapi_startkit import Application + + +class StorageManager: + """File storage manager handling managing files with different drivers.""" + + def __init__(self, application: "Application", store_config: dict = None): + self.application = application + self.drivers = {} + self.store_config = store_config or {} + self.options = {} + + def add_driver(self, name: str, driver: Any): + self.drivers.update({name: driver}) + + def set_configuration(self, config: dict) -> "StorageManager": + self.store_config = config + return self + + def get_driver(self, name: str = None) -> Any: + if name is None: + name = self.store_config.get("default") + + driver_name = self.get_config_options(name).get("driver") + return self.drivers[driver_name] + + def get_config_options(self, name: str = None) -> dict: + disks = self.store_config.get("disks", {}) + if name is None or name == "default": + name = self.store_config.get("default") + + return disks.get(name, {}) + + def disk(self, name: str = "default") -> Any: + """Get the file manager instance for the given disk name.""" + if name == "default": + name = self.store_config.get("default") + + store_config = self.get_config_options(name) + driver = self.get_driver(name) + return driver.set_options(store_config) + + def fake(self, name: str = "default") -> "FakeDriver": + """ + Replace the named disk with a FakeDriver backed by a temp directory. + + The fake uses a plain LocalDriver under the hood (same as Laravel) so + all normal storage operations work, and the returned object exposes + assertion helpers (assertExists, assertMissing, assertCount, …). + + The driver name key that the disk config references (e.g. "s3") is + replaced in self.drivers so that any subsequent Storage.disk(name) + call transparently returns the fake. + """ + if name == "default": + name = self.store_config.get("default", "local") + + from .drivers.fake import FakeDriver + + fake = FakeDriver(self.application, disk_name=name) + + # Resolve which driver key this disk uses (e.g. "s3", "local") and + # replace that slot so get_driver() picks up the fake transparently. + driver_key = self.get_config_options(name).get("driver", name) + self.drivers[driver_key] = fake + + return fake + + def put(self, *args, **kwargs): + return self.disk().put(*args, **kwargs) + + def get(self, *args, **kwargs): + return self.disk().get(*args, **kwargs) + + def exists(self, *args, **kwargs): + return self.disk().exists(*args, **kwargs) + + def missing(self, *args, **kwargs): + return self.disk().missing(*args, **kwargs) + + def stream(self, *args, **kwargs): + return self.disk().stream(*args, **kwargs) + + def copy(self, *args, **kwargs): + return self.disk().copy(*args, **kwargs) + + def move(self, *args, **kwargs): + return self.disk().move(*args, **kwargs) + + def prepend(self, *args, **kwargs): + return self.disk().prepend(*args, **kwargs) + + def append(self, *args, **kwargs): + return self.disk().append(*args, **kwargs) + + def delete(self, *args, **kwargs): + return self.disk().delete(*args, **kwargs) + + def store(self, *args, **kwargs): + return self.disk().store(*args, **kwargs) + + def download(self, *args, **kwargs): + return self.disk().download(*args, **kwargs) + + def url(self, *args, **kwargs): + return self.disk().url(*args, **kwargs) + + +class Storage: + instance = None + + def __init__(self): + from fastapi_startkit.application import app + self.app = app() + self.storage: StorageManager = self.app.make("storage") + + @classmethod + def init(cls) -> StorageManager: + if cls.instance: + return cls.instance.storage + cls.instance = Storage() + return cls.instance.storage + + @classmethod + def disk(cls, name="default"): + return cls.init().disk(name) + + @classmethod + def fake(cls, name="default"): + return cls.init().fake(name) + + @classmethod + def put(cls, *args, **kwargs): + return cls.init().put(*args, **kwargs) + + @classmethod + def get(cls, *args, **kwargs): + return cls.init().get(*args, **kwargs) + + @classmethod + def exists(cls, *args, **kwargs): + return cls.init().exists(*args, **kwargs) + + @classmethod + def missing(cls, *args, **kwargs): + return cls.init().missing(*args, **kwargs) + + @classmethod + def stream(cls, *args, **kwargs): + return cls.init().stream(*args, **kwargs) + + @classmethod + def copy(cls, *args, **kwargs): + return cls.init().copy(*args, **kwargs) + + @classmethod + def move(cls, *args, **kwargs): + return cls.init().move(*args, **kwargs) + + @classmethod + def prepend(cls, *args, **kwargs): + return cls.init().prepend(*args, **kwargs) + + @classmethod + def append(cls, *args, **kwargs): + return cls.init().append(*args, **kwargs) + + @classmethod + def delete(cls, *args, **kwargs): + return cls.init().delete(*args, **kwargs) + + @classmethod + def store(cls, *args, **kwargs): + return cls.init().store(*args, **kwargs) + + @classmethod + def download(cls, *args, **kwargs): + return cls.init().download(*args, **kwargs) + + @classmethod + def url(cls, *args, **kwargs): + return cls.init().url(*args, **kwargs) diff --git a/fastapi_startkit/tests/storage/test_storage.py b/fastapi_startkit/tests/storage/test_storage.py new file mode 100644 index 00000000..36755c61 --- /dev/null +++ b/fastapi_startkit/tests/storage/test_storage.py @@ -0,0 +1,109 @@ +import unittest +from unittest.mock import MagicMock, patch +from fastapi_startkit.storage.storage import StorageManager, Storage + +class TestStorage(unittest.TestCase): + def setUp(self): + self.mock_app = MagicMock() + self.config = { + "default": "local", + "disks": { + "local": { + "driver": "local", + "root": "/tmp/storage", + }, + "s3": { + "driver": "s3", + "key": "aws-key", + "secret": "aws-secret", + "region": "us-east-1", + "bucket": "my-bucket", + } + } + } + self.storage_manager = StorageManager(self.mock_app).set_configuration(self.config) + self.mock_driver = MagicMock() + self.storage_manager.add_driver("local", self.mock_driver) + + # Reset Storage singleton + Storage.instance = None + + def test_get_config_options(self): + self.assertEqual(self.storage_manager.get_config_options("local")["root"], "/tmp/storage") + self.assertEqual(self.storage_manager.get_config_options("default")["root"], "/tmp/storage") + + def test_disk_resolution(self): + self.mock_driver.set_options.return_value = self.mock_driver + driver = self.storage_manager.disk("local") + self.assertEqual(driver, self.mock_driver) + self.mock_driver.set_options.assert_called_with(self.config["disks"]["local"]) + + @patch("fastapi_startkit.application.app") + def test_storage_proxy_methods(self, mock_app_getter): + mock_app_getter.return_value = self.mock_app + self.mock_app.make.return_value = self.storage_manager + + # Mock disk() to return the mock driver + self.mock_driver.set_options.return_value = self.mock_driver + + # Test proxying put + Storage.put("test.txt", "content") + self.mock_driver.put.assert_called_with("test.txt", "content") + + # Test disk selection proxy + Storage.disk("local") + self.mock_driver.set_options.assert_called() + + def test_storage_singleton_behavior(self): + with patch("fastapi_startkit.application.app") as mock_app_getter: + mock_app_getter.return_value = self.mock_app + self.mock_app.make.return_value = self.storage_manager + + s1 = Storage.init() + s2 = Storage.init() + self.assertEqual(s1, s2) + self.assertEqual(s1, self.storage_manager) + + def test_local_driver_download(self): + from fastapi_startkit.storage.drivers.local import LocalDriver + from fastapi.responses import FileResponse + + driver = LocalDriver(self.mock_app) + driver.set_options({"root": "/tmp"}) + + with patch("os.makedirs"): + response = driver.download("test.txt") + self.assertIsInstance(response, FileResponse) + self.assertEqual(response.path, "/tmp/test.txt") + + def test_s3_driver_download(self): + from fastapi_startkit.storage.drivers.s3 import S3Driver + from fastapi.responses import RedirectResponse + + driver = S3Driver(self.mock_app) + driver.set_options({"bucket": "test-bucket", "key": "key", "secret": "secret"}) + + with patch.object(driver, "get_client") as mock_client_getter: + mock_client = MagicMock() + mock_client_getter.return_value = mock_client + mock_client.generate_presigned_url.return_value = "https://s3.url" + + response = driver.download("test.txt") + self.assertIsInstance(response, RedirectResponse) + self.assertEqual(response.headers["location"], "https://s3.url") + + def test_storage_fake(self): + with patch("fastapi_startkit.application.app") as mock_app_getter: + mock_app_getter.return_value = self.mock_app + self.mock_app.make.return_value = self.storage_manager + + # Fake the 's3' disk + Storage.fake('s3') + + # The driver for 's3' should now be a LocalDriver (the fake) + from fastapi_startkit.storage.drivers.local import LocalDriver + driver = self.storage_manager.get_driver('s3') + self.assertIsInstance(driver, LocalDriver) + + # Verify it uses a temporary directory + self.assertIn('storage_fake_s3_', driver.options['root']) diff --git a/fastapi_startkit/uv.lock b/fastapi_startkit/uv.lock index 277e2571..49ee40c0 100644 --- a/fastapi_startkit/uv.lock +++ b/fastapi_startkit/uv.lock @@ -464,6 +464,10 @@ fastapi = [ { name = "fastapi", extra = ["standard"] }, { name = "itsdangerous" }, ] +inertia = [ + { name = "jinja2" }, + { name = "markupsafe" }, +] mysql = [ { name = "aiomysql" }, ] @@ -499,13 +503,15 @@ requires-dist = [ { name = "fastapi", extras = ["standard"], marker = "extra == 'fastapi'", specifier = ">=0.124.4,<0.125.0" }, { name = "inflection", specifier = ">=0.5.1" }, { name = "itsdangerous", marker = "extra == 'fastapi'", specifier = ">=2.2.0" }, + { name = "jinja2", marker = "extra == 'inertia'", specifier = ">=3.1" }, { name = "jinja2", marker = "extra == 'vite'", specifier = ">=3.1" }, + { name = "markupsafe", marker = "extra == 'inertia'", specifier = ">=2.0" }, { name = "pendulum", specifier = ">=3.1.0,<4.0.0" }, { name = "pydantic", specifier = ">=2.12.5" }, { name = "requests", specifier = ">=2.32.5,<3.0.0" }, { name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'database'", specifier = ">=2.0.38" }, ] -provides-extras = ["fastapi", "database", "sqlite", "postgres", "mysql", "vite"] +provides-extras = ["fastapi", "database", "sqlite", "postgres", "mysql", "vite", "inertia"] [package.metadata.requires-dev] dev = [