Set up Flask REST API with flask-smorest, OpenAPI docs, blueprint architecture, and dataclass models. Use when creating a new Flask API server, building REST endpoints, or setting up a production API.
This skill helps you set up a Flask REST API following a standardized pattern with flask-smorest for OpenAPI documentation, blueprint architecture, and dataclass models for request/response handling.
Use this skill when:
requirements.txt) and dev/test tools (dev-requirements.txt)pytest.ini + tests/ - pytest scoped to tests/ with the project root importable, plus a DB-free smoke testIMPORTANT: Before creating files, ask the user these questions:
"What is your project name?" (e.g., "myapp")
{project_name}.py (e.g., myapp.py)"What features/endpoints do you need?" (e.g., "users", "tokens", "orders")
"Do you need database integration?" (yes/no)
postgres-setup skill for the database layer. Use its Step 7 ("Create Resilient Database Driver") to scaffold src/{project_name}/database.py β the singleton manager below imports Database from that exact path. The resilient pattern (pre-ping + retry + mid-flight death detection) is what keeps the Flask process from wedging when Postgres restarts; the naΓ―ve ThreadedConnectionPool pattern returns 500s on every request until the process restarts."What port should the server run on?" (default: 5000)
"Do you want a Swagger UI docs endpoint at /swagger?" (yes/no β default no)
flask-smorest's Api() / Blueprint / abort machinery all still work without any OPENAPI_URL_PREFIX / OPENAPI_SWAGGER_UI_* config (as long as API_TITLE / API_VERSION / OPENAPI_VERSION are set, which they are in the base template).swagger-ui-dist assets under /static/swagger-ui/ (or an internal mirror) β this skill no longer defaults to a third-party CDN (previous versions pointed OPENAPI_SWAGGER_UI_URL at cdn.jsdelivr.net, which is a supply-chain surface and doesn't work in air-gapped/tailnet-only environments).Create these directories if they don't exist:
{project_root}/
βββ blueprints/ # Blueprint modules (one per feature)
β βββ __init__.py
β βββ {feature}.py
βββ models/ # Dataclass models with to_dict/from_dict
β βββ __init__.py
β βββ {feature}.py
βββ tests/ # pytest tests (see Step 9b)
β βββ test_app.py
βββ pytest.ini # pytest config (see Step 9b)
βββ {project_name}.py # Main application file
Create {project_name}.py using this template:
import os
import logging
from flask import Flask
from flask_cors import CORS
from flask_smorest import Api
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def create_app():
app = Flask(__name__)
# These three are ALWAYS required by flask-smorest's `Api()`, even
# when no Swagger UI is served β `Api()` refuses to initialize without
# them. They govern the OpenAPI spec object flask-smorest builds in
# memory (used for validation and, if enabled, doc-surface serving).
app.config['API_TITLE'] = '{Project Name} API'
app.config['API_VERSION'] = 'v1'
app.config['OPENAPI_VERSION'] = '3.0.2'
CORS(app)
api = Api(app)
from blueprints.{feature} import blp as {feature}_blp
api.register_blueprint({feature}_blp)
logger.info("Flask app initialized")
return app
if __name__ == '__main__':
port = int(os.environ.get('PORT', {port_number}))
app = create_app()
app.run(host='0.0.0.0', port=port)
CRITICAL: Replace:
{Project Name} β Human-readable project name (e.g., "My App"){project_name} β Snake case project name (e.g., "myapp"){port_number} β Actual port number (e.g., 5151){feature} β Feature name from user's responseIf the project uses the database, validate its settings at the very top of create_app(). Add the import with the others at the top of {project_name}.py:
from common import DatabaseConfig
and make this the first statement inside create_app():
# Fail fast on a misconfigured deploy. Without this, a missing
# {PROJECT_NAME}_DB_PASSWORD boots "healthy" (/health never touches the DB)
# and only surfaces as 500s on the first DB request. Under gunicorn a
# ValueError here fails the worker boot, so the container exits and
# restart-count alerts fire.
#
# Validates env vars ONLY β it deliberately opens no connection. Do NOT
# replace it with service_manager.get_database(): the driver opens pool
# connections on construction, so a Postgres outage at boot would
# crash-loop the service instead of letting the resilient driver
# (postgres-setup Step 7) reconnect once Postgres is back. It would also
# make the Step 9b smoke test need a live database.
DatabaseConfig.from_env()
common.py is created in Step 6.
If the user opted in to a Swagger UI docs endpoint, add these three
config lines to create_app() immediately after OPENAPI_VERSION, and
add the startup-log line inside the __main__ block:
# Only present when Swagger UI is opted in (Step 1 Q5).
# Vendor `swagger-ui-dist` assets under `/static/swagger-ui/` (or an
# internal mirror you control) and set OPENAPI_SWAGGER_UI_URL to the
# path you're serving them from. DO NOT point this at cdn.jsdelivr.net
# or any third-party CDN β prior skill versions did, which was both a
# supply-chain surface and a hard break in air-gapped / tailnet-only
# deployments.
app.config['OPENAPI_URL_PREFIX'] = '/'
app.config['OPENAPI_SWAGGER_UI_PATH'] = '/swagger'
app.config['OPENAPI_SWAGGER_UI_URL'] = '/static/swagger-ui/' # TODO: vendor
And inside the if __name__ == '__main__': block, immediately after
app = create_app():
logger.info(f"Swagger UI: http://localhost:{port}/swagger")
Vendoring reminder: whichever path you point OPENAPI_SWAGGER_UI_URL
at, that path must actually serve the swagger-ui-dist JS/CSS bundle. The
usual approach is pip install swagger-ui-bundle (or copy the files from
the npm package) and configure Flask to serve them from /static/swagger-ui/.
If that path 404s, /swagger renders a broken shell.
For each feature, create a models file with dataclasses that include to_dict and from_dict methods:
models/{feature}.pyfrom dataclasses import dataclass
from typing import Optional
@dataclass
class {Feature}:
"""
{Feature} data model.
Includes validation in from_dict and serialization via to_dict.
"""
id: str
name: str
created_at: int
updated_at: Optional[int] = None
def to_dict(self) -> dict:
"""Serialize to dictionary for JSON response."""
return {
"id": self.id,
"name": self.name,
"created_at": self.created_at,
"updated_at": self.updated_at
}
@classmethod
def from_dict(cls, data: dict) -> "{Feature}":
"""
Create instance from dictionary with validation.
Args:
data: Dictionary with {feature} data
Returns:
{Feature} instance
Raises:
ValueError: If required fields are missing or invalid
"""
if "id" not in data:
raise ValueError("id is required")
if "name" not in data:
raise ValueError("name is required")
if "created_at" not in data:
raise ValueError("created_at is required")
return cls(
id=str(data["id"]),
name=str(data["name"]),
created_at=int(data["created_at"]),
updated_at=int(data["updated_at"]) if data.get("updated_at") else None
)
CRITICAL: Replace:
{Feature} β PascalCase feature name (e.g., "TradableToken"){feature} β Snake case feature name (e.g., "tradable_token")For each feature/endpoint, create a blueprint file:
blueprints/{feature}.pyimport logging
from flask import request, jsonify
from flask.views import MethodView
from flask_smorest import Blueprint, abort
from werkzeug.exceptions import HTTPException
from models.{feature} import {Feature}
logger = logging.getLogger(__name__)
blp = Blueprint('{feature}', __name__, url_prefix='/api', description='{Feature} API')
@blp.route('/{feature}')
class {Feature}ListResource(MethodView):
def get(self):
"""Get list of {feature}s."""
try:
limit = request.args.get('limit', 100, type=int)
offset = request.args.get('offset', 0, type=int)
# TODO: Implement logic to fetch {feature}s
items = []
return jsonify({
"data": [item.to_dict() for item in items],
"limit": limit,
"offset": offset
})
except HTTPException:
raise
except ValueError as e:
logger.warning(f"Bad request: {e}")
abort(400, message=str(e))
except Exception as e:
logger.exception(f"Error fetching {feature}s: {e}")
abort(500, message="Internal server error")
def post(self):
"""Create a new {feature}."""
try:
data = request.get_json()
if not data:
abort(400, message="Request body is required")
item = {Feature}.from_dict(data)
# TODO: Implement logic to save {feature}
return jsonify(item.to_dict()), 201
except HTTPException:
raise
except ValueError as e:
logger.warning(f"Validation error: {e}")
abort(400, message=str(e))
except Exception as e:
logger.exception(f"Error creating {feature}: {e}")
abort(500, message="Internal server error")
@blp.route('/{feature}/<string:item_id>')
class {Feature}Resource(MethodView):
def get(self, item_id: str):
"""Get a single {feature} by ID."""
try:
# TODO: Implement logic to fetch {feature} by ID
item = None
if not item:
abort(404, message=f"{Feature} not found: {item_id}")
return jsonify(item.to_dict())
except HTTPException:
raise
except Exception as e:
logger.exception(f"Error fetching {feature}: {e}")
abort(500, message="Internal server error")
def put(self, item_id: str):
"""Update a {feature}."""
try:
data = request.get_json()
if not data:
abort(400, message="Request body is required")
# TODO: Implement logic to update {feature}
return jsonify({"message": "Updated"})
except HTTPException:
raise
except ValueError as e:
logger.warning(f"Validation error: {e}")
abort(400, message=str(e))
except Exception as e:
logger.exception(f"Error updating {feature}: {e}")
abort(500, message="Internal server error")
def delete(self, item_id: str):
"""Delete a {feature}."""
try:
# TODO: Implement logic to delete {feature}
return jsonify({"message": "Deleted"})
except HTTPException:
raise
except Exception as e:
logger.exception(f"Error deleting {feature}: {e}")
abort(500, message="Internal server error")
CRITICAL: Replace:
{Feature} β PascalCase feature name (e.g., "TradableToken"){feature} β Snake case feature name (e.g., "tradable_token")CRITICAL: Every handler above starts its except chain with
except HTTPException: raise. This arm is REQUIRED β flask_smorest.abort()
raises werkzeug.exceptions.HTTPException, which is an Exception subclass,
so any abort(400, ...) / abort(404, ...) fired inside a try block will
otherwise be swallowed by the terminal except Exception arm and re-emitted
as abort(500, "Internal server error") β clients get 500s for their own bad
input, and logs fill with spurious logger.exception stack traces for
routine validation. When adding a new handler, keep this arm as the FIRST
except clause. Move unconditional aborts (e.g., if not data: abort(400))
outside the try when possible; the arm still catches the inevitable in-try
aborts (if not item: abort(404) after a lookup).
If the project needs shared services (database, API clients, etc.), create a singleton manager:
common.py"""
Singleton manager for shared service instances.
Provides centralized initialization of database connections, API clients,
and other shared resources.
"""
import os
import logging
from dataclasses import dataclass
logger = logging.getLogger(__name__)
@dataclass
class DatabaseConfig:
"""Database connection settings, read from the same env vars postgres-setup provisions against."""
host: str
port: int
name: str
user: str
password: str
@classmethod
def from_env(cls) -> "DatabaseConfig":
"""
Read and validate database settings from the environment.
Opens NO connection, so it is safe to call at startup even while
Postgres is down. create_app() calls it to fail fast on a
misconfigured deploy; get_database() calls it to build the driver.
Raises:
ValueError: If the password is missing/empty or the port is not an integer
"""
# `{PROJECT_NAME}_DB_PORT` MUST be read here β the setup script
# (postgres-setup Step 4) provisions against it, and skipping it here
# silently dials 5432 (Docker-mapped 5433 or multi-instance hosts hit
# this). Ticket 881aa10a fix β five env vars, four surfaces, one invariant.
password = os.environ.get('{PROJECT_NAME}_DB_PASSWORD')
if not password:
raise ValueError("{PROJECT_NAME}_DB_PASSWORD environment variable required")
return cls(
host=os.environ.get('{PROJECT_NAME}_DB_HOST', 'localhost'),
port=int(os.environ.get('{PROJECT_NAME}_DB_PORT', '5432')),
name=os.environ.get('{PROJECT_NAME}_DB_NAME', '{project_name}'),
user=os.environ.get('{PROJECT_NAME}_DB_USER', '{project_name}'),
password=password,
)
class ServiceManager:
"""
Singleton manager for shared service instances.
Ensures only one instance of each service is created and reused
across all blueprints.
"""
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super(ServiceManager, cls).__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self):
if self._initialized:
return
# Initialize services
self._db = None
self._initialized = True
logger.info("ServiceManager initialized")
def get_database(self):
"""
Get database connection instance.
Returns:
Database connection instance (lazy initialization)
"""
if self._db is None:
# Import database driver β see postgres-setup Step 7 for the
# resilient implementation (pre-ping + retry; survives PG restart).
from src.{project_name}.database import Database
# Connection settings come from DatabaseConfig so startup
# validation (create_app) and the real driver read identical values.
config = DatabaseConfig.from_env()
self._db = Database(config.host, config.name, config.user, config.password, db_port=config.port)
logger.info("Database connection initialized")
return self._db
# Global singleton instance
service_manager = ServiceManager()
CRITICAL: Replace:
{PROJECT_NAME} β Uppercase project name (e.g., "MYAPP"){project_name} β Snake case project name (e.g., "myapp")example.envCreate or update example.env with required environment variables:
# Server Configuration
PORT={port_number}
DEBUG=False
# Database Configuration (if applicable)
{PROJECT_NAME}_DB_HOST=localhost
{PROJECT_NAME}_DB_PORT=5432
{PROJECT_NAME}_DB_NAME={project_name}
{PROJECT_NAME}_DB_USER={project_name}
{PROJECT_NAME}_DB_PASSWORD=your_password_here
# Optional: CORS Configuration
ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080
CRITICAL: Replace:
{port_number} β Actual port number (e.g., 5151){PROJECT_NAME} β Uppercase project name (e.g., "MYAPP"){project_name} β Snake case project name (e.g., "myapp").env (gitignored)Instruct the user to copy example.env to .env and fill in actual values:
# Copy example.env to .env and update with actual values
cp example.env .env
Add .env to .gitignore if not already present:
# Environment variables
.env
Create requirements.txt with dependencies (no version pinning):
# Flask and API framework
Flask
flask-smorest
flask-cors
# Environment variable management
python-dotenv
# Production server (optional but recommended)
gunicorn
# Database (if needed)
psycopg2-binary
Create dev-requirements.txt with test tooling. Keep it separate from requirements.txt: a service's requirements.txt is what gets installed into its Docker image, and test tools don't belong there (same split as the uv-supply-chain-hardening skill uses for apps):
pytest
Create blueprints/__init__.py:
"""
Blueprint modules for {Project Name} API.
Each blueprint represents a distinct feature or resource endpoint.
"""
pytest.ini[pytest]
# REQUIRED. The venv lives at the project root (bin/, lib/, include/), so a
# bare `pytest` otherwise collects the venv's own site-packages tests and dies
# on third-party collection errors before running a single project test.
testpaths = tests
# REQUIRED. The app modules ({project_name}.py, blueprints/, models/, common.py)
# live at the project root, not in an installed package. pytest's default
# import mode puts tests/ on sys.path, not the root, so without this line
# `import {project_name}` fails with ModuleNotFoundError.
pythonpath = .
Use pytest.ini, not pyproject.toml: a Flask service usually has no pyproject.toml, and creating one just for pytest config invites confusion with the library skills.
tests/test_app.pyfrom {project_name} import create_app
def test_create_app_registers_routes() -> None:
# Builds the app and checks wiring only β no requests are sent, so this
# stays DB-free even after the TODO handlers start querying Postgres.
# Keep create_app() itself free of live-DB requirements so this runs
# anywhere (CI, a fresh clone, a laptop without Postgres).
app = create_app()
rules = [rule.rule for rule in app.url_map.iter_rules()]
assert '/api/{feature}' in rules
CRITICAL: Replace:
{project_name} β Snake case project name (e.g., "myapp"){feature} β the first feature's snake case name (e.g., "tradable_token")If the project uses the database (Step 3's fail-fast check is present), use this version of tests/test_app.py instead. create_app() now requires the DB env vars, but still opens no connection, so a dummy password is enough β no Postgres needed:
import pytest
from {project_name} import create_app
def test_create_app_registers_routes(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv('{PROJECT_NAME}_DB_PASSWORD', 'test-not-a-real-password')
app = create_app()
rules = [rule.rule for rule in app.url_map.iter_rules()]
assert '/api/{feature}' in rules
def test_create_app_fails_fast_without_db_password(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv('{PROJECT_NAME}_DB_PASSWORD', raising=False)
with pytest.raises(ValueError):
create_app()
Also replace {PROJECT_NAME} β uppercase project name (e.g., "MYAPP").
Create or update README.md with:
# Copy example environment file
cp example.env .env
# Edit .env and fill in actual values
# Create and activate the virtual environment (at the project root)
python3 -m venv .
source bin/activate
# Then install dependencies
pip install --upgrade -r requirements.txt
pip install --upgrade -r dev-requirements.txt
source bin/activate && pytest
pytest.ini scopes collection to tests/ and puts the project root on the import path, so a bare pytest from the project root works.
# Development mode
python {project_name}.py
# Production mode with Gunicorn
gunicorn -w 4 -b 0.0.0.0:{port_number} '{project_name}:create_app()'
Copy example.env to .env and configure:
Server Configuration:
PORT - Server port (default: {port_number})DEBUG - Enable debug mode (default: False)Database (if applicable):
{PROJECT_NAME}_DB_HOST - Database host (default: localhost){PROJECT_NAME}_DB_PORT - Database port (default: 5432){PROJECT_NAME}_DB_NAME - Database name (default: {project_name}){PROJECT_NAME}_DB_USER - Database user (default: {project_name}){PROJECT_NAME}_DB_PASSWORD - Database password (REQUIRED β the app refuses to start without it)The app checks that these are set when it starts, but does not connect to Postgres until the first request that needs it β so it starts even if Postgres is temporarily down, and fails immediately if the password is missing.
(Only applicable if Swagger UI was opted in during Step 1 Q5.)
If enabled, access Swagger UI at:
http://localhost:{port_number}/swagger
Requires swagger-ui-dist assets to be served under
OPENAPI_SWAGGER_UI_URL; see the Swagger UI enrichment note in Step 3.
This pattern follows these principles:
create_app() pattern for testing and flexibilityIf database is needed, use postgres-setup skill first:
User: "Set up postgres database for my project"
Important: When running postgres-setup, also follow its Step 7 ("Create Resilient Database Driver") to scaffold src/{project_name}/database.py. The singleton manager above imports Database from that path. Skipping Step 7 and using a naΓ―ve ThreadedConnectionPool will wedge the Flask process every time Postgres restarts.
Then reference the database in your blueprints via the singleton manager:
from common import service_manager
db = service_manager.get_database()
If publishing as a package, use python-lib-setup skill:
User: "Set up Python package for PyPI"
User: "Set up Flask API server for my project"
Claude: "What is your project name?"
User: "crypto-tracker"
Claude: "What features/endpoints do you need?"
User: "prices, tokens, portfolio"
Claude: "Do you need database integration?"
User: "yes"
Claude: "What port should the server run on?"
User: "8080"
Claude:
crypto_tracker.py with Flask appmodels/ directory with dataclass models:prices.py, tokens.py, portfolio.pyblueprints/ directory with endpoint handlers:prices.py, tokens.py, portfolio.pycommon.py with ServiceManager singletonrequirements.txt with dependencies and dev-requirements.txt with pytestpytest.ini and tests/test_app.py (DB-free smoke test)If user requests Docker, reference the flask-docker-deployment skill for production-ready containerization with automated versioning and health checks.
If you hit a bug, a stale instruction, or a step that doesn't work while running the flask-smorest-api skill, report it β don't just silently work around it. Future runs will hit the same thing.
byteforge-skills-maintainer-agent (find it with discover_agents if you don't have its project id). Include:flask-smorest-api) and the version from .claude-plugin/plugin.json if you know itFix the user's immediate problem first; report second.