diff --git a/docs/apps.md b/docs/apps.md index dc70830..fdcf5c4 100644 --- a/docs/apps.md +++ b/docs/apps.md @@ -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. diff --git a/docs/auth.md b/docs/auth.md index d26c1fe..2526966 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -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)): ... ``` diff --git a/poetry.lock b/poetry.lock index 57e1e68..c30b765 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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" diff --git a/pyproject.toml b/pyproject.toml index 6db719c..16265c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohmyapi" -version = "0.6.2" +version = "0.4.7" 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"] diff --git a/src/ohmyapi/__init__.py b/src/ohmyapi/__init__.py index 4191a45..01c4965 100644 --- a/src/ohmyapi/__init__.py +++ b/src/ohmyapi/__init__.py @@ -1 +1 @@ -__VERSION__ = "0.6.2" +__VERSION__ = "0.4.7" diff --git a/src/ohmyapi/builtin/auth/models.py b/src/ohmyapi/builtin/auth/models.py index b9785c7..831ebae 100644 --- a/src/ohmyapi/builtin/auth/models.py +++ b/src/ohmyapi/builtin/auth/models.py @@ -1,39 +1,53 @@ +import hmac +import hashlib +import base64 +from functools import wraps +from secrets import token_bytes +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, 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 uuid import UUID +import settings pwd_context = CryptContext(schemes=["argon2"], deprecated="auto") +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") + + +class Group(Model): + id: UUID = field.data.UUIDField(pk=True) + name: str = field.CharField(max_length=42, index=True) + + def __str__(self): + return self.name if self.name else "" class User(Model): id: UUID = field.data.UUIDField(pk=True) username: str = field.CharField(max_length=150, unique=True) - email_hash: str = field.CharField(max_length=255, unique=True, index=True) + email_hash: Optional[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", - } + exclude = ["password_hash", "email_hash"] def __str__(self): fields = { - 'username': self.username, + 'username': self.username if self.username else "-", 'is_admin': 'y' if self.is_admin else 'n', 'is_staff': 'y' if self.is_staff else 'n', } diff --git a/src/ohmyapi/builtin/auth/routes.py b/src/ohmyapi/builtin/auth/routes.py index fa2d4e7..3d7be7f 100644 --- a/src/ohmyapi/builtin/auth/routes.py +++ b/src/ohmyapi/builtin/auth/routes.py @@ -6,9 +6,8 @@ from fastapi import APIRouter, Body, Depends, Header, HTTPException, Request, st 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 ohmyapi.builtin.auth.models import Group, User import jwt import settings @@ -53,6 +52,7 @@ class Claims(BaseModel): type: str sub: str user: ClaimsUser + roles: List[str] exp: str @@ -79,7 +79,7 @@ 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), @@ -88,6 +88,7 @@ def claims(token_type: TokenType, user: User = []) -> Claims: is_admin=user.is_admin, is_staff=user.is_staff, ), + roles=[g.name for g in groups], exp="", ) @@ -199,25 +200,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 +219,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]) diff --git a/src/ohmyapi/builtin/auth/utils.py b/src/ohmyapi/builtin/auth/utils.py deleted file mode 100644 index e54a5da..0000000 --- a/src/ohmyapi/builtin/auth/utils.py +++ /dev/null @@ -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") - diff --git a/src/ohmyapi/cli.py b/src/ohmyapi/cli.py index fd41084..a530424 100644 --- a/src/ohmyapi/cli.py +++ b/src/ohmyapi/cli.py @@ -165,13 +165,6 @@ def createsuperuser(root: str = "."): ) 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()) diff --git a/src/ohmyapi/core/runtime.py b/src/ohmyapi/core/runtime.py index dea8d78..8fedc3c 100644 --- a/src/ohmyapi/core/runtime.py +++ b/src/ohmyapi/core/runtime.py @@ -342,9 +342,9 @@ class App: except ModuleNotFoundError: return - installer = getattr(mod, "install", None) - if installer is not None: - for middleware in installer(): + getter = getattr(mod, "get", None) + if getter is not None: + for middleware in getter(): self._middlewares.append(middleware) def __serialize_route(self, route): @@ -404,15 +404,10 @@ class App: """ 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, + "models": [ + f"{self.name}.{m.__name__}" for m in self._models[f"{self.name}.models"] + ], "middlewares": self.__serialize_middleware(), "routes": self.__serialize_router(), } diff --git a/src/ohmyapi/db/functions.py b/src/ohmyapi/db/functions.py deleted file mode 100644 index c5cf5de..0000000 --- a/src/ohmyapi/db/functions.py +++ /dev/null @@ -1 +0,0 @@ -from tortoise.functions import * diff --git a/src/ohmyapi/db/model/model.py b/src/ohmyapi/db/model/model.py index 7ab918f..929ff60 100644 --- a/src/ohmyapi/db/model/model.py +++ b/src/ohmyapi/db/model/model.py @@ -31,18 +31,6 @@ 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) diff --git a/src/ohmyapi/middleware/cors.py b/src/ohmyapi/middleware/cors.py index 77d49b6..49852cd 100644 --- a/src/ohmyapi/middleware/cors.py +++ b/src/ohmyapi/middleware/cors.py @@ -15,12 +15,12 @@ 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, - { +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), - } -) + }), +] +