A modular Flask API framework with auto CRUD and migrations
Project description
Wapp Framework — README (Quickstart + Progressive Tutorial)
Wapp is a modular, plug-and-play framework for building Flask APIs with automatic CRUD endpoints, nested wapps, and Alembic migrations—with optional Swagger UI docs.
This guide takes you from zero to a multi-wapp API in 5 staged steps:
- Bootstrap, 2) Basic example, 3) Nested stats wapp, 4) Add Meetings wapp, 5) Conditional endpoints by
ENV_APP_TYPE.
0) Prerequisites & Install
pip install saitech-wapp flask flask_sqlalchemy alembic flasgger python-dotenv pydantic
If you’re on Windows + SQLite, keep paths absolute (we’ll normalize below).
1) Bootstrap
Create a project:
myproject/
app.py
app_env.py
app_factory.py
migrate_app.py
migrations/
env.py
versions/
.env
1.1 app_env.py (DB + env setup, robust SQLite path)
# app_env.py
import os
from pathlib import Path
from flask_sqlalchemy import SQLAlchemy
from dotenv import load_dotenv
load_dotenv()
db = SQLAlchemy()
BASE_DIR = Path(__file__).resolve().parent
def normalize_sqlite_url(url: str) -> str:
if url.startswith("sqlite:///"):
rel = url[len("sqlite:///"):]
db_path = (BASE_DIR / rel).resolve()
db_path.parent.mkdir(parents=True, exist_ok=True)
return "sqlite:///" + db_path.as_posix()
return url
RAW_DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///instance/app.db")
DATABASE_URL = normalize_sqlite_url(RAW_DATABASE_URL)
ENV = os.getenv("ENV", "development")
ENV_APP_TYPE = os.getenv("ENV_APP_TYPE", "manager") # "manager" | "public"
1.2 app_factory.py (create Flask app, bind wapps, enable Swagger)
# app_factory.py
from flask import Flask
from flasgger import Swagger
from app_env import db, DATABASE_URL, ENV_APP_TYPE
from wapp.core import Wapp
# --- Define your wapps in later stages; keep this scaffold now ---
class MyWapp(Wapp):
"""Root container wapp. We'll add child wapps in later stages."""
class Wapps:
pass
def create_app(*, bind: bool = True):
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = DATABASE_URL
import os
os.makedirs("instance", exist_ok=True)
db.init_app(app)
if bind:
# Register endpoints only when serving (migrations import this factory too)
MyWapp.bind(app, db)
Swagger(app) # Swagger UI at /apidocs by default
# If you prefer /docs:
# Swagger(app, config={"specs_route": "/docs/"})
return app
1.3 migrate_app.py (Python driver for Alembic: diff → revision → upgrade)
# migrate_app.py
import os, sys
from pathlib import Path
from alembic import command
from alembic.config import Config
from alembic.autogenerate import compare_metadata
from alembic.migration import MigrationContext
from app_factory import create_app
from app_env import db, DATABASE_URL
BASE_DIR = Path(__file__).resolve().parent
os.chdir(BASE_DIR) # ensure cwd
MIGRATIONS_DIR = BASE_DIR / "migrations"
def alembic_config() -> Config:
cfg = Config()
cfg.set_main_option("script_location", str(MIGRATIONS_DIR))
cfg.set_main_option("sqlalchemy.url", DATABASE_URL)
return cfg
def ensure_dirs():
MIGRATIONS_DIR.mkdir(parents=True, exist_ok=True)
(MIGRATIONS_DIR / "versions").mkdir(parents=True, exist_ok=True)
def has_diff() -> bool:
app = create_app(bind=False)
with app.app_context():
with db.engine.connect() as conn:
mc = MigrationContext.configure(conn)
diffs = compare_metadata(mc, db.metadata)
return bool(diffs)
def autogen_if_needed(cfg: Config, message="autogenerate"):
def _process(ctx, rev, directives):
if not directives:
return
script = directives[0]
if not getattr(script, "upgrade_ops", None) or script.upgrade_ops.is_empty():
directives[:] = []
print("No schema changes detected.")
command.revision(cfg, message=message, autogenerate=True,
process_revision_directives=_process)
def main(argv=None):
argv = argv or sys.argv[1:]
cfg = alembic_config()
ensure_dirs()
if not argv:
if has_diff():
autogen_if_needed(cfg)
else:
print("No schema changes; skipping revision.")
command.upgrade(cfg, "head")
return
cmd = argv[0]
if cmd == "check":
changed = has_diff()
print("diff:changed" if changed else "diff:none")
sys.exit(1 if changed else 0)
if cmd == "revision":
autogen_if_needed(cfg); return
if cmd == "upgrade":
command.upgrade(cfg, argv[1] if len(argv) > 1 else "head"); return
if cmd == "downgrade":
command.downgrade(cfg, argv[1] if len(argv) > 1 else "-1"); return
if cmd in {"current", "history", "stamp"}:
getattr(command, cmd)(cfg, *(argv[1:])); return
print(f"Unknown command: {cmd}"); sys.exit(2)
if __name__ == "__main__":
main()
1.4 migrations/env.py (Alembic env pointing at app metadata)
# migrations/env.py
from logging.config import fileConfig
from alembic import context
from app_factory import create_app
from app_env import db, DATABASE_URL
config = context.config
if not config.get_main_option("sqlalchemy.url"):
config.set_main_option("sqlalchemy.url", DATABASE_URL)
if config.config_file_name:
fileConfig(config.config_file_name)
target_metadata = db.metadata
def run_migrations_offline():
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
compare_type=True,
compare_server_default=True,
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
app = create_app(bind=False)
with app.app_context():
connectable = db.engine
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
compare_server_default=True,
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
1.5 app.py (dev runner)
# app.py
import sys, subprocess
from app_env import ENV
from app_factory import create_app
if __name__ == '__main__':
if ENV == 'development':
subprocess.run([sys.executable, "-m", "migrate_app"], check=True)
app = create_app(bind=True)
app.run(debug=True)
1.6 .env (example)
ENV=development
ENV_APP_TYPE=manager
DATABASE_URL=sqlite:///instance/app.db
Run it:
python app.py
# Swagger UI: http://127.0.0.1:5000/apidocs
2) Basic Example — Single Wapp, User model, CRUD
Create users_wapp.py:
# users_wapp.py
from app_env import db
from wapp.core import Wapp
class User(db.Model):
__tablename__ = 'user'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
class WappModel:
slug = "user"
name = "User"
def as_dict(self):
return {c.name: getattr(self, c.name) for c in self.__table__.columns}
class UsersWapp(Wapp):
class Models:
user = User
class Endpoints:
_user = True # autogenerate GET/GET(list)/POST/PUT/DELETE
Register it in app_factory.py:
# app_factory.py (replace the MyWapp definition)
from users_wapp import UsersWapp
class MyWapp(Wapp):
class Wapps:
users = UsersWapp # available under /users
Restart. You’ll have:
GET /users/user/— list usersPOST /users/user/— createGET /users/user/<id>— getPUT /users/user/<id>— updateDELETE /users/user/<id>— delete
3) Add a nested stats wapp under Users
Create users_stats_wapp.py with custom endpoints:
# users_stats_wapp.py
from flask import jsonify
from wapp.core import Wapp
from wapp.endpoint_base import WappEndpoint
from app_env import db
from users_wapp import User
class UsersCountEndpoint(WappEndpoint):
"""User count
---
tags: [Users Stats]
responses:
200:
description: Count of users
schema:
type: object
properties: { count: { type: integer } }
"""
class Meta:
method = 'GET'
pattern = '/stats/count'
name = 'Users Count'
description = 'Total number of users'
def handle(self, request, query, path, body):
total = db.session.query(User).count()
return jsonify({"count": total})
class UsersTopDomainsEndpoint(WappEndpoint):
"""Users top email domains
---
tags: [Users Stats]
responses:
200:
description: List of domains
schema:
type: array
items: { type: string }
"""
class Meta:
method = 'GET'
pattern = '/stats/top-domains'
name = 'Users Top Domains'
description = 'Top email domains'
def handle(self, request, query, path, body):
rows = db.session.execute("""
SELECT substr(email, instr(email, '@')+1) AS domain, COUNT(*) c
FROM user GROUP BY domain ORDER BY c DESC LIMIT 5
""")
return jsonify([r[0] for r in rows])
class UsersRecentEndpoint(WappEndpoint):
"""Recent users
---
tags: [Users Stats]
responses:
200:
description: Recent users
schema:
type: array
items: { type: object }
"""
class Meta:
method = 'GET'
pattern = '/stats/recent'
name = 'Users Recent'
description = 'Most recent users'
def handle(self, request, query, path, body):
rows = User.query.order_by(User.id.desc()).limit(5).all()
return self.to_response(rows)
class UsersStatsWapp(Wapp):
class Endpoints:
users_count = UsersCountEndpoint
users_top_domains = UsersTopDomainsEndpoint
users_recent = UsersRecentEndpoint
Nest it inside UsersWapp:
# users_wapp.py
from users_stats_wapp import UsersStatsWapp
class UsersWapp(Wapp):
class Models:
user = User
class Endpoints:
_user = True
class Wapps:
stats = UsersStatsWapp # routes under /users/stats/...
Now you have:
/users/stats/count/users/stats/top-domains/users/stats/recent
4) Add a side-by-side Meetings wapp
meetings_wapp.py:
# meetings_wapp.py
from app_env import db
from wapp.core import Wapp
class Meeting(db.Model):
__tablename__ = 'meeting'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(120), nullable=False)
notes = db.Column(db.Text)
class WappModel:
slug = "meeting"
name = "Meeting"
def as_dict(self):
return {c.name: getattr(self, c.name) for c in self.__table__.columns}
class MeetingsWapp(Wapp):
class Models:
meeting = Meeting
class Endpoints:
_meeting = True # full CRUD (we’ll refine in step 5)
Register in the root:
# app_factory.py
from users_wapp import UsersWapp
from meetings_wapp import MeetingsWapp
class MyWapp(Wapp):
class Wapps:
users = UsersWapp
meetings = MeetingsWapp
5) Conditional Meetings endpoints by ENV_APP_TYPE
ENV_APP_TYPE=manager→ full CRUD + a stats endpointENV_APP_TYPE=public→ onlylist+get
Update meetings_wapp.py:
# meetings_wapp.py
import os
from flask import jsonify
from app_env import db, ENV_APP_TYPE
from wapp.core import Wapp
from wapp.endpoint_base import WappEndpoint
class Meeting(db.Model):
__tablename__ = 'meeting'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(120), nullable=False)
notes = db.Column(db.Text)
class WappModel:
slug = "meeting"
name = "Meeting"
def as_dict(self):
return {c.name: getattr(self, c.name) for c in self.__table__.columns}
# Optional manager-only stats
class MeetingsCountEndpoint(WappEndpoint):
"""Meetings count
---
tags: [Meetings]
responses:
200:
description: Count of meetings
schema:
type: object
properties: { count: { type: integer } }
"""
class Meta:
method = 'GET'
pattern = '/meeting/stats/count'
name = 'Meetings Count'
def handle(self, request, query, path, body):
total = db.session.query(Meeting).count()
return jsonify({"count": total})
class MeetingsWapp(Wapp):
class Models:
meeting = Meeting
# Decide endpoints based on ENV_APP_TYPE
if ENV_APP_TYPE == "public":
# only list + get
class Endpoints:
_meeting = {
"list": True,
"get": True,
# explicitly disable others
"create": False,
"update": False,
"delete": False,
}
else: # manager (default)
class Endpoints:
_meeting = True
meetings_count = MeetingsCountEndpoint
Set in .env:
ENV_APP_TYPE=public # or manager
Restart and observe Meetings routes adapt accordingly.
Yes—overriding a default (auto-generated) CRUD endpoint is built in. You’ve got three useful patterns:
1) Override one action via the _model dict
Give a custom class for the action you want, let Wapp autogenerate the rest.
from wapp.core import Wapp
from wapp.generic_endpoints import Get # base for GET /<id>
from app_env import db
from users_wapp import User
class UserGetCustom(Get):
"""Get user with extra joins/guards
---
tags: [User]
responses:
200:
description: OK
"""
# keep same route as the auto one:
class Meta(Get.Meta):
method = 'GET'
pattern = f"/{User.WappModel.slug}/<int:id>"
name = "User Get (custom)"
def handle(self, request, query, path, body):
# your custom behavior
obj = self.model.query.get(path['id'])
if not obj:
return self.to_response({"error": "Not found"}), 404
# e.g., add computed fields
data = obj.as_dict()
data["role"] = "admin" if obj.username == "root" else "user"
return self.to_response(data)
class UsersWapp(Wapp):
class Models:
user = User
class Endpoints:
_user = {
"get": UserGetCustom, # 👈 override GET /user/<id>
# list/create/update/delete will be auto-generated
}
2) Override multiple actions, disable others
You can mix custom classes, True (autogen), and False (disable).
class UsersWapp(Wapp):
class Models:
user = User
class Endpoints:
_user = {
"get": UserGetCustom, # custom
"list": True, # autogen
"create": False, # disabled
"update": True, # autogen
# "delete": False, # disabled by default if not present
}
3) Replace all CRUD endpoints with custom ones
Provide a class for each action; nothing will be auto-generated.
class UserListCustom(List): # inherit the matching base for convenience
class Meta(List.Meta):
method = 'GET'
pattern = f"/{User.WappModel.slug}/"
name = "User List (custom)"
def handle(self, request, query, path, body):
return self.to_response(User.query.order_by(User.id.desc()).all())
class UsersWapp(Wapp):
class Models:
user = User
class Endpoints:
_user = {
"get": UserGetCustom,
"list": UserListCustom,
"create": UserCreateCustom,
"update": UserUpdateCustom,
"delete": UserDeleteCustom,
}
Important notes
-
Set
Meta.methodandMeta.patternon custom classes. The auto-generator fills these for you, but when you override with a custom class, you must declare them. Use:method = Wapp.CRUD_ACTIONS['get']['method'] # 'GET' from wherever import what_tickles_your_fancy as custom_pattern pattern = custom_pattern or Wapp.CRUD_ACTIONS['get']['pattern'].format(model_slug=User.WappModel.slug)
or just hardcode as shown.
-
Inherit the right base (
Get,List,Create,Update,Delete) so you keep the same call shape and helpers (to_response,request_modelparsing, etc.). -
Swagger docstring: add a YAML docstring (
"""Summary\n---\n...""") to your custom class; Wapp wraps endpoint classes into plain functions so Flasgger can read that docstring. -
Disable collisions: don’t also set
_user = Trueif you’re overriding via dict; you’ll end up with two handlers on the same path. -
Custom request/response schemas: you can add
request_model/response_modelto yourMeta(if your base uses them) or document via YAML.
That’s it—drop a custom class into the _model dict for the action you want to override, and you’re in full control while keeping the rest auto-generated.
Migrations (autogenerate + apply)
Every time you add/edit models:
# From project root
python -m migrate_app # creates a revision when needed and upgrades to head
# or
python -m migrate_app check # exit code 1 if there are diffs
python -m migrate_app revision # force creating a new revision (if diffs)
python -m migrate_app upgrade # apply
Alembic reads your models via db.metadata inside migrations/env.py and your app_factory.create_app(bind=False) (which avoids registering routes during migration).
Swagger UI
- Default:
/apidocs/ - Change to
/docs/by passingSwagger(app, config={"specs_route": "/docs/"})increate_app.
If you author custom endpoints, you can put minimal YAML in their docstrings (as shown in stats endpoints) to enrich the UI.
Routing Primer
-
Root wapp:
MyWapp -
Child wapps mounted by attribute name:
users→/users/...meetings→/meetings/...
-
CRUD paths are built from each model’s
WappModel.slug.User.WappModel.slug = "user"⇒/users/user/...Meeting.WappModel.slug = "meeting"⇒/meetings/meeting/...
-
Custom endpoints set
Meta.pattern, e.g.'/stats/count'under the current wapp prefix, or fully explicit like'/meeting/stats/count'.
If you want
/users/(no doubleuser/user), setslug=''and adjust patterns—or keep the explicit style as shown for clarity.
Troubleshooting
- Internal Server Error at
/apispec_1.json: usually malformed Swagger YAML or a view object Flasgger can’t inspect. Wapp wraps endpoint classes into function views so Flasgger can parse their docstrings; if you wrote custom views, ensure they’re functions or carry docstrings properly. - SQLite “unable to open database file”: ensure absolute URL normalization (provided in
app_env.py) and thatinstance/exists. - Duplicate endpoints assertion: don’t call
bind()during migrations; this guide usescreate_app(bind=False)in Alembic.
Summary
You now have:
- A root wapp with multiple child wapps
- Auto-CRUD for models with a simple
WappModelmeta and_model = True - Nested wapps for organizational endpoints like stats
- A conditional API surface driven by environment (
ENV_APP_TYPE) - One-command migrations and Swagger UI out of the box
Happy shipping. 🛠️🚀
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file saitech_wapp-0.2.0.tar.gz.
File metadata
- Download URL: saitech_wapp-0.2.0.tar.gz
- Upload date:
- Size: 17.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
71170e499f06be3d4f33d9df58077afc9991f52287dfd81ac0abad68f1f929a8
|
|
| MD5 |
8b99b631fe6907ee8352c6fd7ffa12ad
|
|
| BLAKE2b-256 |
1d5be5e30153890f885e975ba4d076d6086d01fe8343ad1ecda65c8c6f1b7482
|
Provenance
The following attestation bundles were made for saitech_wapp-0.2.0.tar.gz:
Publisher:
python-publish.yml on saitech-org/wapp
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
saitech_wapp-0.2.0.tar.gz -
Subject digest:
71170e499f06be3d4f33d9df58077afc9991f52287dfd81ac0abad68f1f929a8 - Sigstore transparency entry: 511776520
- Sigstore integration time:
-
Permalink:
saitech-org/wapp@595e24f9f5436aa2a103792ae3ec0c7851d51a65 -
Branch / Tag:
refs/tags/0.2.0 - Owner: https://github.com/saitech-org
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
python-publish.yml@595e24f9f5436aa2a103792ae3ec0c7851d51a65 -
Trigger Event:
release
-
Statement type:
File details
Details for the file saitech_wapp-0.2.0-py3-none-any.whl.
File metadata
- Download URL: saitech_wapp-0.2.0-py3-none-any.whl
- Upload date:
- Size: 15.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7875f238723f26f6a9b51fb00f587b96d5b1f83a64da3401092a453da5adcfdb
|
|
| MD5 |
45b15306b5e61bfafc3951c2446b6aec
|
|
| BLAKE2b-256 |
1d2616078e2dfdd5c54f1571db4a387bbc4fb5db3bc158af2bd2d8d1cd77e5b5
|
Provenance
The following attestation bundles were made for saitech_wapp-0.2.0-py3-none-any.whl:
Publisher:
python-publish.yml on saitech-org/wapp
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
saitech_wapp-0.2.0-py3-none-any.whl -
Subject digest:
7875f238723f26f6a9b51fb00f587b96d5b1f83a64da3401092a453da5adcfdb - Sigstore transparency entry: 511776537
- Sigstore integration time:
-
Permalink:
saitech-org/wapp@595e24f9f5436aa2a103792ae3ec0c7851d51a65 -
Branch / Tag:
refs/tags/0.2.0 - Owner: https://github.com/saitech-org
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
python-publish.yml@595e24f9f5436aa2a103792ae3ec0c7851d51a65 -
Trigger Event:
release
-
Statement type: