Compare commits

..

No commits in common. "main" and "0.3.0" have entirely different histories.
main ... 0.3.0

21 changed files with 106 additions and 395 deletions

View file

@ -1,9 +1,8 @@
# OhMyAPI
OhMyAPI is a web-application scaffolding framework and management layer built around `FastAPI`.
It is a thin layer that tightly integrates TortoiseORM and Aerich migrations.
OhMyAPI is a web-application scaffolding framework and management layer built around `FastAPI`, `TortoiseORM` and `Aerich` migrations.
> *Think: *"Django RestFramework"*, but less clunky and instead 100% async.*
> *Think: Django RestFramework, but less clunky and 100% async.*
It is ***blazingly fast***, extremely ***fun to use*** and comes with ***batteries included***!
@ -13,7 +12,7 @@ It is ***blazingly fast***, extremely ***fun to use*** and comes with ***batteri
- Django-like per-app migrations (makemigrations & migrate) via Aerich
- Django-like CLI tooling (startproject, startapp, shell, serve, etc)
- Customizable pydantic model serializer built-in
- Various optional built-in apps you can hook into your project (i.e. authentication and more)
- Various optional built-in apps you can hook into your project
- Highly configurable and customizable
- 100% async

View file

@ -1,7 +1,7 @@
# Apps
Apps are a way to group database models and API routes that contextually belong together.
For example, OhMyAPI comes bundled with an `auth` app that carries a `User` model and provides API endpoints for JWT authentication.
For example, OhMyAPI comes bundled with an `auth` app that carries a `User` and `Group` model and provides API endpoints for JWT authentication.
Apps help organizing projects by isolating individual components (or "features") from one another.

View file

@ -40,6 +40,6 @@ router = APIRouter()
@router.get("/")
def get(user: auth.User = Depends(permissions.require_authenticated)):
def get(user: auth.User = Depends(permissions.required_authenticated)):
...
```

113
poetry.lock generated
View file

@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.2.0 and should not be changed by hand.
[[package]]
name = "aerich"
@ -62,7 +62,7 @@ version = "4.11.0"
description = "High-level concurrency and networking framework on top of asyncio or Trio"
optional = false
python-versions = ">=3.9"
groups = ["main", "dev"]
groups = ["main"]
files = [
{file = "anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc"},
{file = "anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4"},
@ -216,7 +216,7 @@ version = "2025.8.3"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.7"
groups = ["main", "dev"]
groups = ["main"]
files = [
{file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"},
{file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"},
@ -543,66 +543,19 @@ version = "0.16.0"
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
optional = false
python-versions = ">=3.8"
groups = ["main", "dev"]
groups = ["main"]
files = [
{file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
{file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
]
[[package]]
name = "httpcore"
version = "1.0.9"
description = "A minimal low-level HTTP client."
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"},
{file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"},
]
[package.dependencies]
certifi = "*"
h11 = ">=0.16"
[package.extras]
asyncio = ["anyio (>=4.0,<5.0)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
trio = ["trio (>=0.22.0,<1.0)"]
[[package]]
name = "httpx"
version = "0.28.1"
description = "The next generation HTTP client."
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"},
{file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"},
]
[package.dependencies]
anyio = "*"
certifi = "*"
httpcore = "==1.*"
idna = "*"
[package.extras]
brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""]
cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
zstd = ["zstandard (>=0.18.0)"]
[[package]]
name = "idna"
version = "3.10"
description = "Internationalized Domain Names in Applications (IDNA)"
optional = false
python-versions = ">=3.6"
groups = ["main", "dev"]
groups = ["main"]
files = [
{file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
{file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
@ -611,18 +564,6 @@ files = [
[package.extras]
all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
[[package]]
name = "iniconfig"
version = "2.3.0"
description = "brain-dead simple config-ini parsing"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
]
[[package]]
name = "ipython"
version = "9.6.0"
@ -1083,22 +1024,6 @@ docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-a
test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"]
type = ["mypy (>=1.14.1)"]
[[package]]
name = "pluggy"
version = "1.6.0"
description = "plugin and hook calling mechanisms for python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
]
[package.extras]
dev = ["pre-commit", "tox"]
testing = ["coverage", "pytest", "pytest-benchmark"]
[[package]]
name = "prompt-toolkit"
version = "3.0.52"
@ -1335,28 +1260,6 @@ files = [
{file = "pypika_tortoise-0.6.2.tar.gz", hash = "sha256:f95ab59d9b6454db2e8daa0934728458350a1f3d56e81d9d1debc8eebeff26b3"},
]
[[package]]
name = "pytest"
version = "8.4.2"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"},
{file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"},
]
[package.dependencies]
colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""}
iniconfig = ">=1"
packaging = ">=20"
pluggy = ">=1.5,<2"
pygments = ">=2.7.2"
[package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
@ -1585,7 +1488,7 @@ version = "1.3.1"
description = "Sniff out which async library your code is running under"
optional = false
python-versions = ">=3.7"
groups = ["main", "dev"]
groups = ["main"]
files = [
{file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
{file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
@ -1701,7 +1604,7 @@ files = [
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
]
markers = {dev = "python_version < \"3.13\""}
markers = {dev = "python_version == \"3.11\""}
[[package]]
name = "typing-inspection"
@ -1816,4 +1719,4 @@ auth = ["argon2-cffi", "crypto", "passlib", "pyjwt", "python-multipart"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.11"
content-hash = "cc1604995d3b73ee302e63731dd300ea17c4d95d0cfc6c386626dd9a9f60e8a7"
content-hash = "3d301460081dada359d425d69feefc63c1e5135aa64b6f000f554bfc1231febd"

View file

@ -1,6 +1,6 @@
[project]
name = "ohmyapi"
version = "0.6.2"
version = "0.3.0"
description = "Django-flavored scaffolding and management layer around FastAPI, Pydantic, TortoiseORM and Aerich migrations"
license = "MIT"
keywords = ["fastapi", "tortoise", "orm", "pydantic", "async", "web-framework"]
@ -30,8 +30,6 @@ ipython = ">=9.5.0,<10.0.0"
black = "^25.9.0"
isort = "^6.0.1"
mkdocs = "^1.6.1"
pytest = "^8.4.2"
httpx = "^0.28.1"
[project.optional-dependencies]
auth = ["passlib", "pyjwt", "crypto", "argon2-cffi", "python-multipart"]

View file

@ -1 +1 @@
__VERSION__ = "0.6.2"
__VERSION__ = "0.3.0"

View file

@ -1,52 +1,39 @@
from ohmyapi.db import Model, field, Q
from ohmyapi.router import HTTPException
from .utils import hmac_hash
from datetime import datetime
from passlib.context import CryptContext
from typing import Optional
from functools import wraps
from typing import List, Optional
from uuid import UUID
from passlib.context import CryptContext
from tortoise.contrib.pydantic import pydantic_queryset_creator
from ohmyapi.db import Model, field, pre_delete, pre_save
from ohmyapi.router import HTTPException
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
class Group(Model):
id: UUID = field.data.UUIDField(pk=True)
name: str = field.CharField(max_length=42, index=True)
class User(Model):
id: UUID = field.data.UUIDField(pk=True)
email: str = field.CharField(max_length=255, unique=True, index=True)
username: str = field.CharField(max_length=150, unique=True)
email_hash: str = field.CharField(max_length=255, unique=True, index=True)
password_hash: str = field.CharField(max_length=128)
is_admin: bool = field.BooleanField(default=False)
is_staff: bool = field.BooleanField(default=False)
created_at: datetime = field.DatetimeField(auto_now_add=True)
updated_at: datetime = field.DatetimeField(auto_now=True)
groups: field.ManyToManyRelation[Group] = field.ManyToManyField(
"ohmyapi_auth.Group", related_name="users", through="usergroups"
)
class Schema:
include = {
"id",
"username",
"is_admin",
"is_staff"
"created_at",
"updated_at",
}
def __str__(self):
fields = {
'username': self.username,
'is_admin': 'y' if self.is_admin else 'n',
'is_staff': 'y' if self.is_staff else 'n',
}
return ' '.join([f"{k}:{v}" for k, v in fields.items()])
exclude = ("password_hash",)
def set_password(self, raw_password: str) -> None:
"""Hash and store the password."""
self.password_hash = pwd_context.hash(raw_password)
def set_email(self, new_email: str) -> None:
"""Hash and set the e-mail address."""
self.email_hash = hmac_hash(new_email)
def verify_password(self, raw_password: str) -> bool:
"""Verify a plaintext password against the stored hash."""
return pwd_context.verify(raw_password, self.password_hash)

View file

@ -1,7 +1,6 @@
from .routes import (
get_current_user,
get_token,
maybe_authenticated,
require_admin,
require_authenticated,
require_group,

View file

@ -1,17 +1,14 @@
import time
from enum import Enum
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Request, status
from fastapi.security import OAuth2, OAuth2PasswordBearer, OAuth2PasswordRequestForm
from fastapi.security.utils import get_authorization_scheme_param
from pydantic import BaseModel
from tortoise.exceptions import DoesNotExist
from ohmyapi.builtin.auth.models import User
from typing import Any, Dict, List
import jwt
import settings
from fastapi import APIRouter, Body, Depends, Header, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pydantic import BaseModel
from ohmyapi.builtin.auth.models import Group, User
# Router
router = APIRouter(prefix="/auth", tags=["Auth"])
@ -26,25 +23,12 @@ REFRESH_TOKEN_EXPIRE_SECONDS = getattr(
settings, "JWT_REFRESH_TOKEN_EXPIRE_SECONDS", 7 * 24 * 60 * 60
)
class OptionalOAuth2PasswordBearer(OAuth2):
def __init__(self, tokenUrl: str):
super().__init__(flows={"password": {"tokenUrl": tokenUrl}}, scheme_name="OAuth2PasswordBearer")
async def __call__(self, request: Request) -> Optional[str]:
authorization: str = request.headers.get("Authorization")
scheme, param = get_authorization_scheme_param(authorization)
if not authorization or scheme.lower() != "bearer":
# No token provided — just return None
return None
return param
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
oauth2_optional_scheme = OptionalOAuth2PasswordBearer(tokenUrl="/auth/login")
class ClaimsUser(BaseModel):
username: str
email: str
is_admin: bool
is_staff: bool
@ -53,6 +37,7 @@ class Claims(BaseModel):
type: str
sub: str
user: ClaimsUser
roles: List[str]
exp: str
@ -79,15 +64,17 @@ class TokenType(str, Enum):
refresh = "refresh"
def claims(token_type: TokenType, user: User = []) -> Claims:
def claims(token_type: TokenType, user: User, groups: List[Group] = []) -> Claims:
return Claims(
type=token_type,
sub=str(user.id),
user=ClaimsUser(
username=user.username,
email=user.email,
is_admin=user.is_admin,
is_staff=user.is_staff,
),
roles=[g.name for g in groups],
exp="",
)
@ -137,12 +124,6 @@ async def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
return user
async def maybe_authenticated(token: Optional[str] = Depends(oauth2_optional_scheme)) -> Optional[User]:
if token is None:
return None
return await get_current_user(token)
async def require_authenticated(current_user: User = Depends(get_current_user)) -> User:
"""Ensure the current user is an admin."""
if not current_user:
@ -199,25 +180,17 @@ async def login(form_data: LoginRequest = Body(...)):
)
class TokenRefresh(BaseModel):
refresh_token: str
@router.post("/refresh", response_model=AccessToken)
async def refresh_token(refresh_token: TokenRefresh = Body(...)):
async def refresh_token(refresh_token: str):
"""Exchange refresh token for new access token."""
payload = decode_token(refresh_token.refresh_token)
payload = decode_token(refresh_token)
if payload.get("type") != "refresh":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token"
)
user_id = payload.get("sub")
try:
user = await User.get(id=user_id)
except DoesNotExist:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
user = await User.filter(id=user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found"
@ -226,7 +199,7 @@ async def refresh_token(refresh_token: TokenRefresh = Body(...)):
new_access = create_token(
claims(TokenType.access, user), ACCESS_TOKEN_EXPIRE_SECONDS
)
return AccessToken(token_type="bearer", access_token=new_access)
return AccessToken(token_type="bearer", access_token=access_token)
@router.get("/introspect", response_model=Dict[str, Any])

View file

@ -1,17 +0,0 @@
import base64
import hashlib
import hmac
import settings
SECRET_KEY = getattr(settings, "SECRET_KEY", "OhMyAPI Secret Key")
def hmac_hash(data: str) -> str:
digest = hmac.new(
SECRET_KEY.encode("UTF-8"),
data.encode("utf-8"),
hashlib.sha256,
).digest()
return base64.urlsafe_b64encode(digest).decode("utf-8")

View file

@ -9,9 +9,6 @@ import typer
import uvicorn
from ohmyapi.core import runtime, scaffolding
from ohmyapi.core.logging import setup_logging
logger = setup_logging()
app = typer.Typer(
help="OhMyAPI — Django-flavored FastAPI scaffolding with tightly integrated TortoiseORM."
@ -22,24 +19,18 @@ app = typer.Typer(
def startproject(name: str):
"""Create a new OhMyAPI project in the given directory."""
scaffolding.startproject(name)
logger.info(f"✅ Project '{name}' created successfully.")
logger.info(f"🔧 Next, configure your project in {name}/settings.py")
@app.command()
def startapp(app_name: str, root: str = "."):
"""Create a new app with the given name in your OhMyAPI project."""
scaffolding.startapp(app_name, root)
print(f"✅ App '{app_name}' created in project '{root}' successfully.")
print(f"🔧 Remember to add '{app_name}' to your INSTALLED_APPS!")
@app.command()
def dockerize(root: str = "."):
"""Create template Dockerfile and docker-compose.yml."""
scaffolding.copy_static("docker", root)
logger.info(f"✅ Templates created successfully.")
logger.info(f"🔧 Next, run `docker compose up -d --build`")
@app.command()
@ -49,8 +40,9 @@ def serve(root: str = ".", host="127.0.0.1", port=8000):
"""
project_path = Path(root)
project = runtime.Project(project_path)
app_instance = project.configure_app(project.app())
uvicorn.run(app_instance, host=host, port=int(port), reload=False, log_config=None)
app_instance = project.app()
app_instance = project.configure_app(app_instance)
uvicorn.run(app_instance, host=host, port=int(port), reload=False)
@app.command()
@ -83,7 +75,7 @@ def shell(root: str = "."):
asyncio.set_event_loop(loop)
loop.run_until_complete(init_and_cleanup())
# Prepare shell vars that are to be immediately available
# Prepare shell vars that are to be directly available
shell_vars = {"p": project}
try:
@ -115,8 +107,6 @@ def makemigrations(app: str = "*", name: str = "auto", root: str = "."):
asyncio.run(project.makemigrations(app_label=app, name=name))
else:
asyncio.run(project.makemigrations(app_label=app, name=name))
logger.info(f"✅ Migrations created successfully.")
logger.info(f"🔧 To migrate the DB, run `ohmyapi migrate`, next.")
@app.command()
@ -131,7 +121,6 @@ def migrate(app: str = "*", root: str = "."):
asyncio.run(project.migrate(app))
else:
asyncio.run(project.migrate(app))
logger.info(f"✅ Migrations ran successfully.")
@app.command()
@ -161,17 +150,9 @@ def createsuperuser(root: str = "."):
if password1 != password2:
print("Passwords didn't match!")
user = ohmyapi_auth.models.User(
username=username, is_staff=True, is_admin=True
email=email, username=username, is_staff=True, is_admin=True
)
user.set_email(email)
user.set_password(password1)
async def _run():
await project.init_orm()
user = ohmyapi_auth.models.User(username=username, is_staff=True, is_admin=True)
user.set_email(email)
user.set_password(password1)
await user.save()
await project.close_orm()
asyncio.run(_run())
asyncio.run(project.init_orm())
asyncio.run(user.save())
asyncio.run(project.close_orm())

View file

@ -1,38 +0,0 @@
import logging
import os
import sys
def setup_logging():
"""Configure unified logging for ohmyapi + FastAPI/Uvicorn."""
log_level = os.getenv("OHMYAPI_LOG_LEVEL", "INFO").upper()
level = getattr(logging, log_level, logging.INFO)
# Root logger (affects FastAPI, uvicorn, etc.)
logging.basicConfig(
level=level,
format="[%(asctime)s] [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
handlers=[
logging.StreamHandler(sys.stdout)
]
)
# Separate ohmyapi logger (optional)
logger = logging.getLogger("ohmyapi")
# Direct warnings/errors to stderr
class LevelFilter(logging.Filter):
def filter(self, record):
# Send warnings+ to stderr, everything else to stdout
if record.levelno >= logging.WARNING:
record.stream = sys.stderr
else:
record.stream = sys.stdout
return True
for handler in logger.handlers:
handler.addFilter(LevelFilter())
logger.setLevel(level)
return logger

View file

@ -7,7 +7,7 @@ import sys
from http import HTTPStatus
from pathlib import Path
from types import ModuleType
from typing import Any, Dict, Generator, List, Optional, Tuple, Type
from typing import Any, Dict, Generator, List, Optional, Type
import click
from aerich import Command as AerichCommand
@ -15,11 +15,8 @@ from aerich.exceptions import NotInitedError
from fastapi import APIRouter, FastAPI
from tortoise import Tortoise
from ohmyapi.core.logging import setup_logging
from ohmyapi.db.model import Model
logger = setup_logging()
class Project:
"""
@ -32,7 +29,6 @@ class Project:
"""
def __init__(self, project_path: str):
logger.debug(f"Loading project: {project_path}")
self.project_path = Path(project_path).resolve()
self._apps: Dict[str, App] = {}
self.migrations_dir = self.project_path / "migrations"
@ -40,14 +36,6 @@ class Project:
if str(self.project_path) not in sys.path:
sys.path.insert(0, str(self.project_path))
# Load settings.py
try:
self.settings = importlib.import_module("settings")
except Exception as e:
raise RuntimeError(
f"Failed to import project settings from {self.project_path}"
) from e
# Alias builtin apps as ohmyapi_<name>.
# We need this, because Tortoise app-names may not include dots `.`.
spec = importlib.util.find_spec("ohmyapi.builtin")
@ -65,6 +53,14 @@ class Project:
except ModuleNotFoundError:
pass
# Load settings.py
try:
self.settings = importlib.import_module("settings")
except Exception as e:
raise RuntimeError(
f"Failed to import project settings from {self.project_path}"
) from e
# Load installed apps
for app_name in getattr(self.settings, "INSTALLED_APPS", []):
self._apps[app_name] = App(self, name=app_name)
@ -92,14 +88,10 @@ class Project:
def configure_app(self, app: FastAPI) -> FastAPI:
"""
Attach project middlewares and routes and event handlers to given
FastAPI instance.
Attach project routes and event handlers to given FastAPI instance.
"""
app.router.prefix = getattr(self.settings, "API_PREFIX", "")
# Attach project middlewares and routes.
# Attach project routes.
for app_name, app_def in self._apps.items():
for middleware, kwargs in app_def.middlewares:
app.add_middleware(middleware, **kwargs)
for router in app_def.routers:
app.include_router(router)
@ -129,7 +121,7 @@ class Project:
}
for app_name, app in self._apps.items():
modules = list(app._models.keys())
modules = list(app.models.keys())
if modules:
config["apps"][app_name] = {
"models": modules,
@ -154,10 +146,7 @@ class Project:
tortoise_cfg = self.build_tortoise_config(db_url=db_url)
# Prevent leaking other app's models to Aerich.
if app_label in tortoise_cfg["apps"].keys():
tortoise_cfg["apps"] = {app_label: tortoise_cfg["apps"][app_label]}
else:
tortoise_cfg["apps"] = {app_label: {"default_connection": "default", "models": []}}
tortoise_cfg["apps"] = {app_label: tortoise_cfg["apps"][app_label]}
# Append aerich.models to the models list of the target app only
tortoise_cfg["apps"][app_label]["models"].append("aerich.models")
@ -240,16 +229,11 @@ class App:
# Reference to this app's routes modules.
self._routers: Dict[str, ModuleType] = {}
# Reference to this apps middlewares.
self._middlewares: List[Tuple[Any, Dict[str, Any]]] = []
# Import the app, so its __init__.py runs.
mod: ModuleType = importlib.import_module(name)
logger.debug(f"Loading app: {self.name}")
self.__load_models(f"{self.name}.models")
self.__load_routes(f"{self.name}.routes")
self.__load_middlewares(f"{self.name}.middlewares")
def __repr__(self):
return json.dumps(self.dict(), indent=2)
@ -267,6 +251,7 @@ class App:
try:
importlib.import_module(mod_name)
except ModuleNotFoundError:
print(f"no models detected: {mod_name}")
return
# Acoid duplicates.
@ -284,11 +269,9 @@ class App:
and issubclass(value, Model)
and not name == Model.__name__
):
# monkey-patch __module__ to point to well-known aliases
value.__module__ = value.__module__.replace("ohmyapi.builtin.", "ohmyapi_")
if value.__module__.startswith(mod_name):
self._models[mod_name] = self._models.get(mod_name, []) + [value]
logger.debug(f" - Model: {mod_name} -> {name}")
# if it's a package, recurse into submodules
if hasattr(mod, "__path__"):
@ -309,7 +292,8 @@ class App:
# An app may come without any routes.
try:
importlib.import_module(mod_name)
except ModuleNotFoundError:
except ModuleNotFound:
print(f"no routes detected: {mod_name}")
return
# Avoid duplicates.
@ -324,7 +308,6 @@ class App:
for name, value in vars(mod).copy().items():
if isinstance(value, APIRouter) and not name == APIRouter.__name__:
self._routers[mod_name] = self._routers.get(mod_name, []) + [value]
logger.debug(f" - Router: {mod_name} -> {name} -> {value.routes}")
# if it's a package, recurse into submodules
if hasattr(mod, "__path__"):
@ -336,17 +319,6 @@ class App:
# Walk the walk.
walk(mod_name)
def __load_middlewares(self, mod_name):
try:
mod = importlib.import_module(mod_name)
except ModuleNotFoundError:
return
installer = getattr(mod, "install", None)
if installer is not None:
for middleware in installer():
self._middlewares.append(middleware)
def __serialize_route(self, route):
"""
Convert APIRoute to JSON-serializable dict.
@ -360,12 +332,6 @@ class App:
def __serialize_router(self):
return [self.__serialize_route(route) for route in self.routes]
def __serialize_middleware(self):
out = []
for m in self.middlewares:
out.append((m[0].__name__, m[1]))
return out
@property
def models(self) -> List[ModuleType]:
"""
@ -375,7 +341,9 @@ class App:
for module in self._models:
for model in self._models[module]:
out.append(model)
return out
return {
module: out,
}
@property
def routers(self):
@ -395,24 +363,13 @@ class App:
out.extend(r.routes)
return out
@property
def middlewares(self):
"""Returns the list of this app's middlewares."""
return self._middlewares
def dict(self) -> Dict[str, Any]:
"""
Convenience method for serializing the runtime data.
"""
# An app may come without any models
models = []
if f"{self.name}.models" in self._models:
models = [
f"{self.name}.{m.__name__}"
for m in self._models[f"{self.name}.models"]
]
return {
"models": models,
"middlewares": self.__serialize_middleware(),
"models": [
f"{self.name}.{m.__name__}" for m in self.models[f"{self.name}.models"]
],
"routes": self.__serialize_router(),
}

View file

@ -2,16 +2,12 @@ from pathlib import Path
from jinja2 import Environment, FileSystemLoader
from ohmyapi.core.logging import setup_logging
import shutil
# Base templates directory
TEMPLATE_DIR = Path(__file__).parent / "templates"
env = Environment(loader=FileSystemLoader(str(TEMPLATE_DIR)))
logger = setup_logging()
def render_template_file(template_path: Path, context: dict, output_path: Path):
"""Render a single Jinja2 template file to disk."""
@ -64,7 +60,7 @@ def copy_static(dir_name: str, target_dir: Path):
template_dir = TEMPLATE_DIR / dir_name
target_dir = Path(target_dir)
if not template_dir.exists():
logger.error(f"no templates found under: {dir_name}")
print(f"no templates found under: {dir_name}")
return
for root, _, files in template_dir.walk():
@ -73,15 +69,19 @@ def copy_static(dir_name: str, target_dir: Path):
src = root_path / file
dst = target_dir / file
if dst.exists():
logger.warning(f"⛔ File exists, skipping: {dst}")
print(f"⛔ File exists, skipping: {dst}")
continue
shutil.copy(src, dst)
shutil.copy(src, ".")
print(f"✅ Templates created successfully.")
print(f"🔧 Next, run `docker compose up -d --build`")
def startproject(name: str):
"""Create a new project: flat structure, all project templates go into <name>/"""
target_dir = Path(name).resolve()
target_dir.mkdir(exist_ok=True)
render_template_dir("project", target_dir, {"project_name": name})
print(f"✅ Project '{name}' created successfully.")
print(f"🔧 Next, configure your project in {target_dir / 'settings.py'}")
def startapp(name: str, project: str):
@ -94,3 +94,5 @@ def startapp(name: str, project: str):
{"project_name": target_dir.resolve().name, "app_name": name},
subdir_name=name,
)
print(f"✅ App '{name}' created in project '{target_dir}' successfully.")
print(f"🔧 Remember to add '{name}' to your INSTALLED_APPS!")

View file

@ -21,6 +21,7 @@ ENV PATH="$POETRY_HOME/bin:$PATH"
WORKDIR /app
COPY pyproject.toml poetry.lock* /app/
RUN poetry lock
RUN poetry install
COPY . /app

View file

@ -1,14 +1,4 @@
services:
# db:
# image: postgres:latest
# restart: unless-stopped
# environment:
# POSTGRES_DB: ohmyapi
# POSTGRES_USER: ohmyapi
# POSTGRES_PASSWORD: ohmyapi
# ports:
# - 5432:5432
app:
build:
context: .

View file

@ -10,7 +10,7 @@ readme = "README.md"
license = { text = "MIT" }
dependencies = [
# "asyncpg"
"ohmyapi"
]
[tool.poetry.group.dev.dependencies]

View file

@ -1,6 +1,4 @@
from tortoise.expressions import Q
from tortoise.manager import Manager
from tortoise.query_utils import Prefetch
from tortoise.queryset import QuerySet
from tortoise.signals import (
post_delete,

View file

@ -1 +0,0 @@
from tortoise.functions import *

View file

@ -31,21 +31,26 @@ UUID.__get_pydantic_core_schema__ = classmethod(__uuid_schema_monkey_patch)
class ModelMeta(type(TortoiseModel)):
def __new__(mcls, name, bases, attrs):
meta = attrs.get("Meta", None)
if meta is None:
class Meta:
pass
meta = Meta
attrs["Meta"] = meta
if not hasattr(meta, "table"):
# Use first part of module as app_label
app_label = attrs.get("__module__", "").replace("ohmyapi.builtin.", "ohmyapi_").split(".")[0]
setattr(meta, "table", f"{app_label}_{name.lower()}")
# Grab the Schema class for further processing.
schema_opts = attrs.get("Schema", None)
# Create or get the inner Meta class
meta = attrs.get("Meta", type("Meta", (), {}))
# Infer app name from module if not explicitly set
if not hasattr(meta, "app"):
module = attrs.get("__module__", "")
module_parts = module.split(".")
if module_parts[-1] == "models":
inferred_app = module_parts[-2]
else:
inferred_app = module_parts[-1]
meta.app = inferred_app
# Prefix table name if not explicitly set
if not hasattr(meta, "table"):
meta.table = f"{meta.app}_{name.lower()}"
# Let Tortoise's Metaclass do it's thing.
new_cls = super().__new__(mcls, name, bases, attrs)

View file

@ -1,26 +0,0 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from typing import Any, Dict, List, Tuple
import settings
DEFAULT_ORIGINS = ["http://localhost", "http://localhost:8000"]
DEFAULT_CREDENTIALS = False
DEFAULT_METHODS = ["*"]
DEFAULT_HEADERS = ["*"]
CORS_CONFIG: Dict[str, Any] = getattr(settings, "MIDDLEWARE_CORS", {})
if not isinstance(CORS_CONFIG, dict):
raise ValueError("MIDDLEWARE_CORS must be of type dict")
middleware = (
CORSMiddleware,
{
"allow_origins": CORS_CONFIG.get("ALLOW_ORIGINS", DEFAULT_ORIGINS),
"allow_credentials": CORS_CONFIG.get("ALLOW_CREDENTIALS", DEFAULT_CREDENTIALS),
"allow_methods": CORS_CONFIG.get("ALLOW_METHODS", DEFAULT_METHODS),
"allow_headers": CORS_CONFIG.get("ALLOW_HEADERS", DEFAULT_HEADERS),
}
)