Compare commits
61 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5691f3133 | ||
|
|
5f80a7a86f | ||
|
|
b588ebcf8a | ||
|
|
a9b88d87d6 | ||
|
|
7163fe778e | ||
|
|
458ffc6b2c | ||
|
|
22ca522615 | ||
|
|
8c2cf01f40 | ||
|
|
9d2e284da3 | ||
|
|
ed30291a4c | ||
|
|
31f4da773c | ||
|
|
715b7a030a | ||
|
|
4a5bafd889 | ||
|
|
b50cbc4341 | ||
|
|
10681cc15b | ||
|
|
58f1387aaf | ||
|
|
6b87bfeefb | ||
|
|
812e89ede9 | ||
|
|
e25c9d1715 | ||
|
|
d494396728 | ||
|
|
2e1ec5d780 | ||
|
|
ce47e3f60e | ||
|
|
acd4844a25 | ||
|
|
74f625ab1d | ||
|
|
a45f03b92f | ||
|
|
de043ddd97 | ||
|
|
80163ce994 | ||
|
|
66176e9af7 | ||
|
|
cf106e8855 | ||
|
|
d7f7db338f | ||
|
|
643a6b2eb7 | ||
|
|
1c42b44d41 | ||
|
|
2239480dc0 | ||
|
|
3e15aa7722 | ||
|
|
e1f5ce589c | ||
|
|
4ec2f87ce2 | ||
|
|
e3abc642ed | ||
|
|
b1222c64d6 | ||
|
|
1ef64c2b17 | ||
|
|
49c24b7f0c | ||
|
|
4c11297d65 | ||
|
|
c23576b393 | ||
|
|
0deb5706f8 | ||
|
|
f579972466 | ||
|
|
cb9acd52d0 | ||
|
|
67d89a94f4 | ||
|
|
e848601f79 | ||
|
|
6120c151f1 | ||
|
|
3e029e1fb7 | ||
|
|
9ac95af005 | ||
|
|
33a9c94042 | ||
|
|
cc3872cf74 | ||
|
|
5c632cbe8f | ||
|
|
ad8986abb1 | ||
|
|
bcdd23652f | ||
|
|
856ea12f52 | ||
|
|
bb8884f419 | ||
|
|
63d2c31763 | ||
|
|
64e98f9f0a | ||
|
|
c411a9795c | ||
|
|
e2f968bac4 |
21 changed files with 455 additions and 83 deletions
|
|
@ -1,8 +1,9 @@
|
||||||
# OhMyAPI
|
# OhMyAPI
|
||||||
|
|
||||||
OhMyAPI is a web-application scaffolding framework and management layer built around `FastAPI`, `TortoiseORM` and `Aerich` migrations.
|
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.
|
||||||
|
|
||||||
> *Think: Django RestFramework, but less clunky and 100% async.*
|
> *Think: *"Django RestFramework"*, but less clunky and instead 100% async.*
|
||||||
|
|
||||||
It is ***blazingly fast***, extremely ***fun to use*** and comes with ***batteries included***!
|
It is ***blazingly fast***, extremely ***fun to use*** and comes with ***batteries included***!
|
||||||
|
|
||||||
|
|
@ -12,7 +13,7 @@ It is ***blazingly fast***, extremely ***fun to use*** and comes with ***batteri
|
||||||
- Django-like per-app migrations (makemigrations & migrate) via Aerich
|
- Django-like per-app migrations (makemigrations & migrate) via Aerich
|
||||||
- Django-like CLI tooling (startproject, startapp, shell, serve, etc)
|
- Django-like CLI tooling (startproject, startapp, shell, serve, etc)
|
||||||
- Customizable pydantic model serializer built-in
|
- Customizable pydantic model serializer built-in
|
||||||
- Various optional built-in apps you can hook into your project
|
- Various optional built-in apps you can hook into your project (i.e. authentication and more)
|
||||||
- Highly configurable and customizable
|
- Highly configurable and customizable
|
||||||
- 100% async
|
- 100% async
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Apps
|
# Apps
|
||||||
|
|
||||||
Apps are a way to group database models and API routes that contextually belong together.
|
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` and `Group` model and provides API endpoints for JWT authentication.
|
For example, OhMyAPI comes bundled with an `auth` app that carries a `User` model and provides API endpoints for JWT authentication.
|
||||||
|
|
||||||
Apps help organizing projects by isolating individual components (or "features") from one another.
|
Apps help organizing projects by isolating individual components (or "features") from one another.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,6 @@ router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
@router.get("/")
|
||||||
def get(user: auth.User = Depends(permissions.required_authenticated)):
|
def get(user: auth.User = Depends(permissions.require_authenticated)):
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
|
||||||
113
poetry.lock
generated
113
poetry.lock
generated
|
|
@ -1,4 +1,4 @@
|
||||||
# This file is automatically @generated by Poetry 2.2.0 and should not be changed by hand.
|
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aerich"
|
name = "aerich"
|
||||||
|
|
@ -62,7 +62,7 @@ version = "4.11.0"
|
||||||
description = "High-level concurrency and networking framework on top of asyncio or Trio"
|
description = "High-level concurrency and networking framework on top of asyncio or Trio"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
groups = ["main"]
|
groups = ["main", "dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc"},
|
{file = "anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc"},
|
||||||
{file = "anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4"},
|
{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."
|
description = "Python package for providing Mozilla's CA Bundle."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.7"
|
python-versions = ">=3.7"
|
||||||
groups = ["main"]
|
groups = ["main", "dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"},
|
{file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"},
|
||||||
{file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"},
|
{file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"},
|
||||||
|
|
@ -543,19 +543,66 @@ version = "0.16.0"
|
||||||
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
|
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.8"
|
python-versions = ">=3.8"
|
||||||
groups = ["main"]
|
groups = ["main", "dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
|
{file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
|
||||||
{file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
|
{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]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "3.10"
|
version = "3.10"
|
||||||
description = "Internationalized Domain Names in Applications (IDNA)"
|
description = "Internationalized Domain Names in Applications (IDNA)"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.6"
|
python-versions = ">=3.6"
|
||||||
groups = ["main"]
|
groups = ["main", "dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
|
{file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
|
||||||
{file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
|
{file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
|
||||||
|
|
@ -564,6 +611,18 @@ files = [
|
||||||
[package.extras]
|
[package.extras]
|
||||||
all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
|
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]]
|
[[package]]
|
||||||
name = "ipython"
|
name = "ipython"
|
||||||
version = "9.6.0"
|
version = "9.6.0"
|
||||||
|
|
@ -1024,6 +1083,22 @@ 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)"]
|
test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"]
|
||||||
type = ["mypy (>=1.14.1)"]
|
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]]
|
[[package]]
|
||||||
name = "prompt-toolkit"
|
name = "prompt-toolkit"
|
||||||
version = "3.0.52"
|
version = "3.0.52"
|
||||||
|
|
@ -1260,6 +1335,28 @@ files = [
|
||||||
{file = "pypika_tortoise-0.6.2.tar.gz", hash = "sha256:f95ab59d9b6454db2e8daa0934728458350a1f3d56e81d9d1debc8eebeff26b3"},
|
{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]]
|
[[package]]
|
||||||
name = "python-dateutil"
|
name = "python-dateutil"
|
||||||
version = "2.9.0.post0"
|
version = "2.9.0.post0"
|
||||||
|
|
@ -1488,7 +1585,7 @@ version = "1.3.1"
|
||||||
description = "Sniff out which async library your code is running under"
|
description = "Sniff out which async library your code is running under"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.7"
|
python-versions = ">=3.7"
|
||||||
groups = ["main"]
|
groups = ["main", "dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
|
{file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
|
||||||
{file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
|
{file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
|
||||||
|
|
@ -1604,7 +1701,7 @@ files = [
|
||||||
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
|
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
|
||||||
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
|
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
|
||||||
]
|
]
|
||||||
markers = {dev = "python_version == \"3.11\""}
|
markers = {dev = "python_version < \"3.13\""}
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typing-inspection"
|
name = "typing-inspection"
|
||||||
|
|
@ -1719,4 +1816,4 @@ auth = ["argon2-cffi", "crypto", "passlib", "pyjwt", "python-multipart"]
|
||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.1"
|
lock-version = "2.1"
|
||||||
python-versions = ">=3.11"
|
python-versions = ">=3.11"
|
||||||
content-hash = "3d301460081dada359d425d69feefc63c1e5135aa64b6f000f554bfc1231febd"
|
content-hash = "cc1604995d3b73ee302e63731dd300ea17c4d95d0cfc6c386626dd9a9f60e8a7"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "ohmyapi"
|
name = "ohmyapi"
|
||||||
version = "0.2.7"
|
version = "0.6.2"
|
||||||
description = "Django-flavored scaffolding and management layer around FastAPI, Pydantic, TortoiseORM and Aerich migrations"
|
description = "Django-flavored scaffolding and management layer around FastAPI, Pydantic, TortoiseORM and Aerich migrations"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
keywords = ["fastapi", "tortoise", "orm", "pydantic", "async", "web-framework"]
|
keywords = ["fastapi", "tortoise", "orm", "pydantic", "async", "web-framework"]
|
||||||
|
|
@ -30,6 +30,8 @@ ipython = ">=9.5.0,<10.0.0"
|
||||||
black = "^25.9.0"
|
black = "^25.9.0"
|
||||||
isort = "^6.0.1"
|
isort = "^6.0.1"
|
||||||
mkdocs = "^1.6.1"
|
mkdocs = "^1.6.1"
|
||||||
|
pytest = "^8.4.2"
|
||||||
|
httpx = "^0.28.1"
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
auth = ["passlib", "pyjwt", "crypto", "argon2-cffi", "python-multipart"]
|
auth = ["passlib", "pyjwt", "crypto", "argon2-cffi", "python-multipart"]
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
__VERSION__ = "0.2.7"
|
__VERSION__ = "0.6.2"
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,52 @@
|
||||||
from functools import wraps
|
from ohmyapi.db import Model, field, Q
|
||||||
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
|
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
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
|
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):
|
class User(Model):
|
||||||
id: UUID = field.data.UUIDField(pk=True)
|
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)
|
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)
|
password_hash: str = field.CharField(max_length=128)
|
||||||
is_admin: bool = field.BooleanField(default=False)
|
is_admin: bool = field.BooleanField(default=False)
|
||||||
is_staff: bool = field.BooleanField(default=False)
|
is_staff: bool = field.BooleanField(default=False)
|
||||||
groups: field.ManyToManyRelation[Group] = field.ManyToManyField(
|
created_at: datetime = field.DatetimeField(auto_now_add=True)
|
||||||
"ohmyapi_auth.Group", related_name="users", through="usergroups"
|
updated_at: datetime = field.DatetimeField(auto_now=True)
|
||||||
)
|
|
||||||
|
|
||||||
class Schema:
|
class Schema:
|
||||||
exclude = ("password_hash",)
|
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()])
|
||||||
|
|
||||||
def set_password(self, raw_password: str) -> None:
|
def set_password(self, raw_password: str) -> None:
|
||||||
"""Hash and store the password."""
|
"""Hash and store the password."""
|
||||||
self.password_hash = pwd_context.hash(raw_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:
|
def verify_password(self, raw_password: str) -> bool:
|
||||||
"""Verify a plaintext password against the stored hash."""
|
"""Verify a plaintext password against the stored hash."""
|
||||||
return pwd_context.verify(raw_password, self.password_hash)
|
return pwd_context.verify(raw_password, self.password_hash)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from .routes import (
|
from .routes import (
|
||||||
get_current_user,
|
get_current_user,
|
||||||
get_token,
|
get_token,
|
||||||
|
maybe_authenticated,
|
||||||
require_admin,
|
require_admin,
|
||||||
require_authenticated,
|
require_authenticated,
|
||||||
require_group,
|
require_group,
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
import time
|
import time
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any, Dict, List
|
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
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
import settings
|
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
|
||||||
router = APIRouter(prefix="/auth", tags=["Auth"])
|
router = APIRouter(prefix="/auth", tags=["Auth"])
|
||||||
|
|
@ -23,12 +26,25 @@ REFRESH_TOKEN_EXPIRE_SECONDS = getattr(
|
||||||
settings, "JWT_REFRESH_TOKEN_EXPIRE_SECONDS", 7 * 24 * 60 * 60
|
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_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
|
||||||
|
oauth2_optional_scheme = OptionalOAuth2PasswordBearer(tokenUrl="/auth/login")
|
||||||
|
|
||||||
|
|
||||||
class ClaimsUser(BaseModel):
|
class ClaimsUser(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
email: str
|
|
||||||
is_admin: bool
|
is_admin: bool
|
||||||
is_staff: bool
|
is_staff: bool
|
||||||
|
|
||||||
|
|
@ -37,7 +53,6 @@ class Claims(BaseModel):
|
||||||
type: str
|
type: str
|
||||||
sub: str
|
sub: str
|
||||||
user: ClaimsUser
|
user: ClaimsUser
|
||||||
roles: List[str]
|
|
||||||
exp: str
|
exp: str
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -64,17 +79,15 @@ class TokenType(str, Enum):
|
||||||
refresh = "refresh"
|
refresh = "refresh"
|
||||||
|
|
||||||
|
|
||||||
def claims(token_type: TokenType, user: User, groups: List[Group] = []) -> Claims:
|
def claims(token_type: TokenType, user: User = []) -> Claims:
|
||||||
return Claims(
|
return Claims(
|
||||||
type=token_type,
|
type=token_type,
|
||||||
sub=str(user.id),
|
sub=str(user.id),
|
||||||
user=ClaimsUser(
|
user=ClaimsUser(
|
||||||
username=user.username,
|
username=user.username,
|
||||||
email=user.email,
|
|
||||||
is_admin=user.is_admin,
|
is_admin=user.is_admin,
|
||||||
is_staff=user.is_staff,
|
is_staff=user.is_staff,
|
||||||
),
|
),
|
||||||
roles=[g.name for g in groups],
|
|
||||||
exp="",
|
exp="",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -124,6 +137,12 @@ async def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
|
||||||
return 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:
|
async def require_authenticated(current_user: User = Depends(get_current_user)) -> User:
|
||||||
"""Ensure the current user is an admin."""
|
"""Ensure the current user is an admin."""
|
||||||
if not current_user:
|
if not current_user:
|
||||||
|
|
@ -180,17 +199,25 @@ async def login(form_data: LoginRequest = Body(...)):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TokenRefresh(BaseModel):
|
||||||
|
refresh_token: str
|
||||||
|
|
||||||
|
|
||||||
@router.post("/refresh", response_model=AccessToken)
|
@router.post("/refresh", response_model=AccessToken)
|
||||||
async def refresh_token(refresh_token: str):
|
async def refresh_token(refresh_token: TokenRefresh = Body(...)):
|
||||||
"""Exchange refresh token for new access token."""
|
"""Exchange refresh token for new access token."""
|
||||||
payload = decode_token(refresh_token)
|
payload = decode_token(refresh_token.refresh_token)
|
||||||
if payload.get("type") != "refresh":
|
if payload.get("type") != "refresh":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token"
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token"
|
||||||
)
|
)
|
||||||
|
|
||||||
user_id = payload.get("sub")
|
user_id = payload.get("sub")
|
||||||
user = await User.filter(id=user_id).first()
|
try:
|
||||||
|
user = await User.get(id=user_id)
|
||||||
|
except DoesNotExist:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found"
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found"
|
||||||
|
|
@ -199,7 +226,7 @@ async def refresh_token(refresh_token: str):
|
||||||
new_access = create_token(
|
new_access = create_token(
|
||||||
claims(TokenType.access, user), ACCESS_TOKEN_EXPIRE_SECONDS
|
claims(TokenType.access, user), ACCESS_TOKEN_EXPIRE_SECONDS
|
||||||
)
|
)
|
||||||
return AccessToken(token_type="bearer", access_token=access_token)
|
return AccessToken(token_type="bearer", access_token=new_access)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/introspect", response_model=Dict[str, Any])
|
@router.get("/introspect", response_model=Dict[str, Any])
|
||||||
|
|
|
||||||
17
src/ohmyapi/builtin/auth/utils.py
Normal file
17
src/ohmyapi/builtin/auth/utils.py
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
@ -9,6 +9,9 @@ import typer
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
from ohmyapi.core import runtime, scaffolding
|
from ohmyapi.core import runtime, scaffolding
|
||||||
|
from ohmyapi.core.logging import setup_logging
|
||||||
|
|
||||||
|
logger = setup_logging()
|
||||||
|
|
||||||
app = typer.Typer(
|
app = typer.Typer(
|
||||||
help="OhMyAPI — Django-flavored FastAPI scaffolding with tightly integrated TortoiseORM."
|
help="OhMyAPI — Django-flavored FastAPI scaffolding with tightly integrated TortoiseORM."
|
||||||
|
|
@ -19,12 +22,24 @@ app = typer.Typer(
|
||||||
def startproject(name: str):
|
def startproject(name: str):
|
||||||
"""Create a new OhMyAPI project in the given directory."""
|
"""Create a new OhMyAPI project in the given directory."""
|
||||||
scaffolding.startproject(name)
|
scaffolding.startproject(name)
|
||||||
|
logger.info(f"✅ Project '{name}' created successfully.")
|
||||||
|
logger.info(f"🔧 Next, configure your project in {name}/settings.py")
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def startapp(app_name: str, root: str = "."):
|
def startapp(app_name: str, root: str = "."):
|
||||||
"""Create a new app with the given name in your OhMyAPI project."""
|
"""Create a new app with the given name in your OhMyAPI project."""
|
||||||
scaffolding.startapp(app_name, root)
|
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()
|
@app.command()
|
||||||
|
|
@ -34,13 +49,13 @@ def serve(root: str = ".", host="127.0.0.1", port=8000):
|
||||||
"""
|
"""
|
||||||
project_path = Path(root)
|
project_path = Path(root)
|
||||||
project = runtime.Project(project_path)
|
project = runtime.Project(project_path)
|
||||||
app_instance = project.app()
|
app_instance = project.configure_app(project.app())
|
||||||
app_instance = project.configure_app(app_instance)
|
uvicorn.run(app_instance, host=host, port=int(port), reload=False, log_config=None)
|
||||||
uvicorn.run(app_instance, host=host, port=int(port), reload=False)
|
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def shell(root: str = "."):
|
def shell(root: str = "."):
|
||||||
|
"""An interactive shell with your loaded project runtime."""
|
||||||
project_path = Path(root).resolve()
|
project_path = Path(root).resolve()
|
||||||
project = runtime.Project(project_path)
|
project = runtime.Project(project_path)
|
||||||
|
|
||||||
|
|
@ -68,7 +83,7 @@ def shell(root: str = "."):
|
||||||
asyncio.set_event_loop(loop)
|
asyncio.set_event_loop(loop)
|
||||||
loop.run_until_complete(init_and_cleanup())
|
loop.run_until_complete(init_and_cleanup())
|
||||||
|
|
||||||
# Prepare shell vars that are to be directly available
|
# Prepare shell vars that are to be immediately available
|
||||||
shell_vars = {"p": project}
|
shell_vars = {"p": project}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -100,6 +115,8 @@ def makemigrations(app: str = "*", name: str = "auto", root: str = "."):
|
||||||
asyncio.run(project.makemigrations(app_label=app, name=name))
|
asyncio.run(project.makemigrations(app_label=app, name=name))
|
||||||
else:
|
else:
|
||||||
asyncio.run(project.makemigrations(app_label=app, name=name))
|
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()
|
@app.command()
|
||||||
|
|
@ -114,6 +131,7 @@ def migrate(app: str = "*", root: str = "."):
|
||||||
asyncio.run(project.migrate(app))
|
asyncio.run(project.migrate(app))
|
||||||
else:
|
else:
|
||||||
asyncio.run(project.migrate(app))
|
asyncio.run(project.migrate(app))
|
||||||
|
logger.info(f"✅ Migrations ran successfully.")
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
|
|
@ -143,9 +161,17 @@ def createsuperuser(root: str = "."):
|
||||||
if password1 != password2:
|
if password1 != password2:
|
||||||
print("Passwords didn't match!")
|
print("Passwords didn't match!")
|
||||||
user = ohmyapi_auth.models.User(
|
user = ohmyapi_auth.models.User(
|
||||||
email=email, username=username, is_staff=True, is_admin=True
|
username=username, is_staff=True, is_admin=True
|
||||||
)
|
)
|
||||||
|
user.set_email(email)
|
||||||
user.set_password(password1)
|
user.set_password(password1)
|
||||||
asyncio.run(project.init_orm())
|
|
||||||
asyncio.run(user.save())
|
async def _run():
|
||||||
asyncio.run(project.close_orm())
|
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())
|
||||||
|
|
|
||||||
38
src/ohmyapi/core/logging.py
Normal file
38
src/ohmyapi/core/logging.py
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
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
|
||||||
|
|
@ -7,7 +7,7 @@ import sys
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
from typing import Any, Dict, Generator, List, Optional, Type
|
from typing import Any, Dict, Generator, List, Optional, Tuple, Type
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from aerich import Command as AerichCommand
|
from aerich import Command as AerichCommand
|
||||||
|
|
@ -15,8 +15,11 @@ from aerich.exceptions import NotInitedError
|
||||||
from fastapi import APIRouter, FastAPI
|
from fastapi import APIRouter, FastAPI
|
||||||
from tortoise import Tortoise
|
from tortoise import Tortoise
|
||||||
|
|
||||||
|
from ohmyapi.core.logging import setup_logging
|
||||||
from ohmyapi.db.model import Model
|
from ohmyapi.db.model import Model
|
||||||
|
|
||||||
|
logger = setup_logging()
|
||||||
|
|
||||||
|
|
||||||
class Project:
|
class Project:
|
||||||
"""
|
"""
|
||||||
|
|
@ -29,6 +32,7 @@ class Project:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, project_path: str):
|
def __init__(self, project_path: str):
|
||||||
|
logger.debug(f"Loading project: {project_path}")
|
||||||
self.project_path = Path(project_path).resolve()
|
self.project_path = Path(project_path).resolve()
|
||||||
self._apps: Dict[str, App] = {}
|
self._apps: Dict[str, App] = {}
|
||||||
self.migrations_dir = self.project_path / "migrations"
|
self.migrations_dir = self.project_path / "migrations"
|
||||||
|
|
@ -36,6 +40,14 @@ class Project:
|
||||||
if str(self.project_path) not in sys.path:
|
if str(self.project_path) not in sys.path:
|
||||||
sys.path.insert(0, str(self.project_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>.
|
# Alias builtin apps as ohmyapi_<name>.
|
||||||
# We need this, because Tortoise app-names may not include dots `.`.
|
# We need this, because Tortoise app-names may not include dots `.`.
|
||||||
spec = importlib.util.find_spec("ohmyapi.builtin")
|
spec = importlib.util.find_spec("ohmyapi.builtin")
|
||||||
|
|
@ -53,14 +65,6 @@ class Project:
|
||||||
except ModuleNotFoundError:
|
except ModuleNotFoundError:
|
||||||
pass
|
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
|
# Load installed apps
|
||||||
for app_name in getattr(self.settings, "INSTALLED_APPS", []):
|
for app_name in getattr(self.settings, "INSTALLED_APPS", []):
|
||||||
self._apps[app_name] = App(self, name=app_name)
|
self._apps[app_name] = App(self, name=app_name)
|
||||||
|
|
@ -88,10 +92,14 @@ class Project:
|
||||||
|
|
||||||
def configure_app(self, app: FastAPI) -> FastAPI:
|
def configure_app(self, app: FastAPI) -> FastAPI:
|
||||||
"""
|
"""
|
||||||
Attach project routes and event handlers to given FastAPI instance.
|
Attach project middlewares and routes and event handlers to given
|
||||||
|
FastAPI instance.
|
||||||
"""
|
"""
|
||||||
# Attach project routes.
|
app.router.prefix = getattr(self.settings, "API_PREFIX", "")
|
||||||
|
# Attach project middlewares and routes.
|
||||||
for app_name, app_def in self._apps.items():
|
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:
|
for router in app_def.routers:
|
||||||
app.include_router(router)
|
app.include_router(router)
|
||||||
|
|
||||||
|
|
@ -121,7 +129,7 @@ class Project:
|
||||||
}
|
}
|
||||||
|
|
||||||
for app_name, app in self._apps.items():
|
for app_name, app in self._apps.items():
|
||||||
modules = list(app.models.keys())
|
modules = list(app._models.keys())
|
||||||
if modules:
|
if modules:
|
||||||
config["apps"][app_name] = {
|
config["apps"][app_name] = {
|
||||||
"models": modules,
|
"models": modules,
|
||||||
|
|
@ -146,7 +154,10 @@ class Project:
|
||||||
tortoise_cfg = self.build_tortoise_config(db_url=db_url)
|
tortoise_cfg = self.build_tortoise_config(db_url=db_url)
|
||||||
|
|
||||||
# Prevent leaking other app's models to Aerich.
|
# Prevent leaking other app's models to Aerich.
|
||||||
tortoise_cfg["apps"] = {app_label: tortoise_cfg["apps"][app_label]}
|
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": []}}
|
||||||
|
|
||||||
# Append aerich.models to the models list of the target app only
|
# Append aerich.models to the models list of the target app only
|
||||||
tortoise_cfg["apps"][app_label]["models"].append("aerich.models")
|
tortoise_cfg["apps"][app_label]["models"].append("aerich.models")
|
||||||
|
|
@ -229,11 +240,16 @@ class App:
|
||||||
# Reference to this app's routes modules.
|
# Reference to this app's routes modules.
|
||||||
self._routers: Dict[str, ModuleType] = {}
|
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.
|
# Import the app, so its __init__.py runs.
|
||||||
mod: ModuleType = importlib.import_module(name)
|
mod: ModuleType = importlib.import_module(name)
|
||||||
|
|
||||||
|
logger.debug(f"Loading app: {self.name}")
|
||||||
self.__load_models(f"{self.name}.models")
|
self.__load_models(f"{self.name}.models")
|
||||||
self.__load_routes(f"{self.name}.routes")
|
self.__load_routes(f"{self.name}.routes")
|
||||||
|
self.__load_middlewares(f"{self.name}.middlewares")
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return json.dumps(self.dict(), indent=2)
|
return json.dumps(self.dict(), indent=2)
|
||||||
|
|
@ -251,7 +267,6 @@ class App:
|
||||||
try:
|
try:
|
||||||
importlib.import_module(mod_name)
|
importlib.import_module(mod_name)
|
||||||
except ModuleNotFoundError:
|
except ModuleNotFoundError:
|
||||||
print(f"no models detected: {mod_name}")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Acoid duplicates.
|
# Acoid duplicates.
|
||||||
|
|
@ -269,9 +284,11 @@ class App:
|
||||||
and issubclass(value, Model)
|
and issubclass(value, Model)
|
||||||
and not name == Model.__name__
|
and not name == Model.__name__
|
||||||
):
|
):
|
||||||
|
# monkey-patch __module__ to point to well-known aliases
|
||||||
value.__module__ = value.__module__.replace("ohmyapi.builtin.", "ohmyapi_")
|
value.__module__ = value.__module__.replace("ohmyapi.builtin.", "ohmyapi_")
|
||||||
if value.__module__.startswith(mod_name):
|
if value.__module__.startswith(mod_name):
|
||||||
self._models[mod_name] = self._models.get(mod_name, []) + [value]
|
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 it's a package, recurse into submodules
|
||||||
if hasattr(mod, "__path__"):
|
if hasattr(mod, "__path__"):
|
||||||
|
|
@ -292,8 +309,7 @@ class App:
|
||||||
# An app may come without any routes.
|
# An app may come without any routes.
|
||||||
try:
|
try:
|
||||||
importlib.import_module(mod_name)
|
importlib.import_module(mod_name)
|
||||||
except ModuleNotFound:
|
except ModuleNotFoundError:
|
||||||
print(f"no routes detected: {mod_name}")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Avoid duplicates.
|
# Avoid duplicates.
|
||||||
|
|
@ -308,6 +324,7 @@ class App:
|
||||||
for name, value in vars(mod).copy().items():
|
for name, value in vars(mod).copy().items():
|
||||||
if isinstance(value, APIRouter) and not name == APIRouter.__name__:
|
if isinstance(value, APIRouter) and not name == APIRouter.__name__:
|
||||||
self._routers[mod_name] = self._routers.get(mod_name, []) + [value]
|
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 it's a package, recurse into submodules
|
||||||
if hasattr(mod, "__path__"):
|
if hasattr(mod, "__path__"):
|
||||||
|
|
@ -319,6 +336,17 @@ class App:
|
||||||
# Walk the walk.
|
# Walk the walk.
|
||||||
walk(mod_name)
|
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):
|
def __serialize_route(self, route):
|
||||||
"""
|
"""
|
||||||
Convert APIRoute to JSON-serializable dict.
|
Convert APIRoute to JSON-serializable dict.
|
||||||
|
|
@ -332,6 +360,12 @@ class App:
|
||||||
def __serialize_router(self):
|
def __serialize_router(self):
|
||||||
return [self.__serialize_route(route) for route in self.routes]
|
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
|
@property
|
||||||
def models(self) -> List[ModuleType]:
|
def models(self) -> List[ModuleType]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -341,9 +375,7 @@ class App:
|
||||||
for module in self._models:
|
for module in self._models:
|
||||||
for model in self._models[module]:
|
for model in self._models[module]:
|
||||||
out.append(model)
|
out.append(model)
|
||||||
return {
|
return out
|
||||||
module: out,
|
|
||||||
}
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def routers(self):
|
def routers(self):
|
||||||
|
|
@ -363,13 +395,24 @@ class App:
|
||||||
out.extend(r.routes)
|
out.extend(r.routes)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
@property
|
||||||
|
def middlewares(self):
|
||||||
|
"""Returns the list of this app's middlewares."""
|
||||||
|
return self._middlewares
|
||||||
|
|
||||||
def dict(self) -> Dict[str, Any]:
|
def dict(self) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Convenience method for serializing the runtime data.
|
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 {
|
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(),
|
"routes": self.__serialize_router(),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,16 @@ from pathlib import Path
|
||||||
|
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
|
||||||
|
from ohmyapi.core.logging import setup_logging
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
|
||||||
# Base templates directory
|
# Base templates directory
|
||||||
TEMPLATE_DIR = Path(__file__).parent / "templates"
|
TEMPLATE_DIR = Path(__file__).parent / "templates"
|
||||||
env = Environment(loader=FileSystemLoader(str(TEMPLATE_DIR)))
|
env = Environment(loader=FileSystemLoader(str(TEMPLATE_DIR)))
|
||||||
|
|
||||||
|
logger = setup_logging()
|
||||||
|
|
||||||
|
|
||||||
def render_template_file(template_path: Path, context: dict, output_path: Path):
|
def render_template_file(template_path: Path, context: dict, output_path: Path):
|
||||||
"""Render a single Jinja2 template file to disk."""
|
"""Render a single Jinja2 template file to disk."""
|
||||||
|
|
@ -53,13 +59,29 @@ def render_template_dir(
|
||||||
render_template_file(template_dir / template_rel_path, context, output_path)
|
render_template_file(template_dir / template_rel_path, context, output_path)
|
||||||
|
|
||||||
|
|
||||||
|
def copy_static(dir_name: str, target_dir: Path):
|
||||||
|
"""Statically copy all files from {TEMPLATE_DIR}/{dir_name} to target_dir."""
|
||||||
|
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}")
|
||||||
|
return
|
||||||
|
|
||||||
|
for root, _, files in template_dir.walk():
|
||||||
|
root_path = Path(root)
|
||||||
|
for file in files:
|
||||||
|
src = root_path / file
|
||||||
|
dst = target_dir / file
|
||||||
|
if dst.exists():
|
||||||
|
logger.warning(f"⛔ File exists, skipping: {dst}")
|
||||||
|
continue
|
||||||
|
shutil.copy(src, dst)
|
||||||
|
|
||||||
def startproject(name: str):
|
def startproject(name: str):
|
||||||
"""Create a new project: flat structure, all project templates go into <name>/"""
|
"""Create a new project: flat structure, all project templates go into <name>/"""
|
||||||
target_dir = Path(name).resolve()
|
target_dir = Path(name).resolve()
|
||||||
target_dir.mkdir(exist_ok=True)
|
target_dir.mkdir(exist_ok=True)
|
||||||
render_template_dir("project", target_dir, {"project_name": name})
|
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):
|
def startapp(name: str, project: str):
|
||||||
|
|
@ -72,5 +94,3 @@ def startapp(name: str, project: str):
|
||||||
{"project_name": target_dir.resolve().name, "app_name": name},
|
{"project_name": target_dir.resolve().name, "app_name": name},
|
||||||
subdir_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!")
|
|
||||||
|
|
|
||||||
29
src/ohmyapi/core/templates/docker/Dockerfile
Normal file
29
src/ohmyapi/core/templates/docker/Dockerfile
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
FROM python:3.13-alpine
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
POETRY_HOME="/opt/poetry" \
|
||||||
|
POETRY_VIRTUALENVS_IN_PROJECT=true \
|
||||||
|
POETRY_NO_INTERACTION=1
|
||||||
|
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
build-base \
|
||||||
|
curl \
|
||||||
|
git \
|
||||||
|
bash \
|
||||||
|
libffi-dev \
|
||||||
|
openssl-dev \
|
||||||
|
python3-dev \
|
||||||
|
musl-dev
|
||||||
|
|
||||||
|
RUN curl -sSL https://install.python-poetry.org | python3 -
|
||||||
|
ENV PATH="$POETRY_HOME/bin:$PATH"
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY pyproject.toml poetry.lock* /app/
|
||||||
|
RUN poetry install
|
||||||
|
COPY . /app
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["poetry", "run", "ohmyapi", "serve", "--host", "0.0.0.0"]
|
||||||
17
src/ohmyapi/core/templates/docker/docker-compose.yml
Normal file
17
src/ohmyapi/core/templates/docker/docker-compose.yml
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
services:
|
||||||
|
# db:
|
||||||
|
# image: postgres:latest
|
||||||
|
# restart: unless-stopped
|
||||||
|
# environment:
|
||||||
|
# POSTGRES_DB: ohmyapi
|
||||||
|
# POSTGRES_USER: ohmyapi
|
||||||
|
# POSTGRES_PASSWORD: ohmyapi
|
||||||
|
# ports:
|
||||||
|
# - 5432:5432
|
||||||
|
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- 8000:8000
|
||||||
|
|
@ -10,7 +10,7 @@ readme = "README.md"
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ohmyapi (>=0.1.0,<0.2.0)"
|
# "asyncpg"
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
[tool.poetry.group.dev.dependencies]
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
|
from tortoise.expressions import Q
|
||||||
from tortoise.manager import Manager
|
from tortoise.manager import Manager
|
||||||
|
from tortoise.query_utils import Prefetch
|
||||||
from tortoise.queryset import QuerySet
|
from tortoise.queryset import QuerySet
|
||||||
from tortoise.signals import (
|
from tortoise.signals import (
|
||||||
post_delete,
|
post_delete,
|
||||||
|
|
|
||||||
1
src/ohmyapi/db/functions.py
Normal file
1
src/ohmyapi/db/functions.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
from tortoise.functions import *
|
||||||
|
|
@ -31,6 +31,18 @@ UUID.__get_pydantic_core_schema__ = classmethod(__uuid_schema_monkey_patch)
|
||||||
|
|
||||||
class ModelMeta(type(TortoiseModel)):
|
class ModelMeta(type(TortoiseModel)):
|
||||||
def __new__(mcls, name, bases, attrs):
|
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.
|
# Grab the Schema class for further processing.
|
||||||
schema_opts = attrs.get("Schema", None)
|
schema_opts = attrs.get("Schema", None)
|
||||||
|
|
||||||
|
|
|
||||||
26
src/ohmyapi/middleware/cors.py
Normal file
26
src/ohmyapi/middleware/cors.py
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue