Designing the Sin Pluma Flask REST API

Date
Clock 10 min read
Tag
#python #flask #rest api
Designing the Sin Pluma Flask REST API

There’s a particular failure mode in Flask projects where the app starts as a single file and then slowly becomes a 2,000-line mess as the project grows. Sin Pluma avoids it by using the app factory pattern from the beginning. Every piece of configuration, every extension, every blueprint registers itself through a single create_app() function, and nothing assumes global state. This post walks through how that factory is structured, how requests flow through it, and how the three main integrations (MySQL, Redis, MinIO) slot in.


Purpose and role

The Flask service is the canonical application API for Sin Pluma. It acts as the central layer where business rules live and where the platform enforces its data integrity. This service handles everything that matters to the platform as a service.

  • User authentication and authorization
  • Author and profile management
  • Creation, editing, and publishing of notebooks and pages
  • Media uploads and object storage coordination
  • Lightweight search across published content
  • Session and token lifecycle management, including revocation
  • Coordination with the linguistics engine, MinIO, Redis, and MySQL

Architecturally, Flask functions as the brain of the platform. It enforces invariants, performs transactional updates, and exposes JSON endpoints consumed by the React SPA and any future external clients.


Code layout and runtime

The project structure follows a clear module separation.

flaskService/ app/ models/ # SQLAlchemy models: User, Notebook, Page, Genre, Reading schemas/ # Marshmallow schemas for validation and serialization resources/ # Flask-RESTful resources that define API endpoints auth/ # JWT helpers, authentication decorators, blacklist logic storage/ # MinIO integration helpers db/ # Database engine and session configuration utils/ # Shared utilities: pagination, error handling wsgi.py # Gunicorn entry point requirements.txt config.py # Environment-driven configuration

The service runs behind Gunicorn with multiple workers for concurrent request handling. It’s packaged as a Docker image. Configuration comes entirely from environment variables: database endpoints, MinIO credentials, Redis connection details, and JWT secrets.


Core libraries and why they were chosen

The stack is deliberate and minimal.

Flask keeps the API layer explicit. There’s no magic. Every route, every middleware, every extension is registered in code you can read.

Flask-RESTful adds structured resource routing using class-based endpoints. GET and POST on the same path live in the same class, organized by HTTP method.

Flask-JWT-Extended manages access tokens, refresh tokens, and request authentication. It handles the decorator-based protection pattern and the token creation API.

Flask-SQLAlchemy with PyMySQL provides ORM support and connectivity to the MySQL InnoDB cluster. Connection strings point to MySQL Router, not individual nodes.

Marshmallow and Flask-Marshmallow handle request validation and response serialization. Every request body and response payload goes through a schema.

bcrypt performs secure password hashing. Passwords never touch the database in plaintext.

Gunicorn is the production-grade WSGI server. Multiple workers allow concurrent request handling without threading complexity in application code.


The app factory

The entry point is flaskService/app/__init__.py. It exports one function.

def create_app(): app = Flask(__name__) app.config.from_object('config.settings') # Initialize extensions db.init_app(app) ma.init_app(app) jwt.init_app(app) # Register blueprints from app.resources.user import user_blueprint from app.resources.notebook import notebook_blueprint app.register_blueprint(user_blueprint, url_prefix='/user') app.register_blueprint(notebook_blueprint, url_prefix='/') # JWT callbacks @jwt.token_in_blacklist_loader def check_token_blacklist(decoded_token): jti = decoded_token['jti'] return rc.get(jti) is not None return app

The factory creates a fresh Flask app, loads config from config.settings, initializes SQLAlchemy, Marshmallow, and Flask-JWT-Extended, then registers the two blueprints. The JWT blacklist callback is registered here because it needs access to the jwt extension instance. Running the app is one line in wsgi.py.


Configuration

config/settings.py holds all environment-specific values. A few are worth calling out.

SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://root:root@inno_router:6446/SinPluma' JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY') JWT_BLACKLIST_ENABLED = True JWT_BLACKLIST_TOKEN_CHECKS = ['access', 'refresh'] ACCESS_EXPIRES = timedelta(minutes=15) REFRESH_EXPIRES = timedelta(days=30) JWT_ACCESS_TOKEN_EXPIRES = ACCESS_EXPIRES JWT_REFRESH_TOKEN_EXPIRES = REFRESH_EXPIRES

The database URI connects through MySQL Router (inno_router:6446). Port 6446 is the Router’s read-write endpoint. Flask never talks to a MySQL node directly.


Full endpoint inventory

The API splits into two blueprints: user_blueprint and notebook_blueprint.

Authentication and session

POST /user/login— Authenticate a user and issue JWT tokens. Takes username and password. Verifies credentials against the bcrypt hash. Returns access_token and refresh_token on success.

POST /user/register— Create a new user account. Hashes the password with bcrypt and writes a User record inside a database transaction.

DELETE /user/logout— Invalidate the current session. Extracts the JWT ID from the access token and writes it to the Redis blacklist.

POST /user/token/refresh— Exchange a refresh token for a new access token. Validates the refresh token signature, expiry, and Redis blacklist status.

Users and profiles

GET /user/<username>— Retrieve public profile information. Reads user metadata from MySQL and serializes it through Marshmallow.

PUT /user/settings— Update profile information such as username, email, or password. Requires authentication. The requesting user must match the profile owner. Profile updates occur inside a transactional database write.

GET /user/<username>/image/— Retrieve the profile image for a user. Proxied from MinIO.

POST /user/<username>/image/— Upload a profile image. Streams the file to MinIO and returns the object reference.

Notebooks and works

GET /notebooks/— List notebooks. Returns public notebooks or the current user’s notebooks depending on the auth context. Supports pagination via page and per_page query parameters.

POST /notebooks/— Create a new notebook. Takes title, genre_id, and resume. Inserts the record inside a database transaction and returns the created identifier.

GET /notebooks/<id>— Retrieve notebook metadata along with its list of pages.

PUT /notebooks/<id>— Update notebook metadata. Requires authentication; the requesting user must own the notebook.

DELETE /notebooks/<id>— Remove a notebook. Cascades to associated pages.

GET /notebooks/<id>/image/— Retrieve the notebook cover image from MinIO.

POST /notebooks/<id>/image/— Upload a notebook cover image. Validates MIME type and file size, streams to MinIO, records metadata in MySQL inside a transaction.

Pages

GET /pages/— List pages belonging to a notebook. Accepts notebook_id as a query parameter. Returns pages ordered by position.

POST /pages/— Create a new page within a notebook. Takes title, content, and notebook_id. The position is assigned as the next available slot.

GET /pages/<id>— Retrieve the content of a specific page. Accepts a mode query parameter: editor returns the full draft, reader returns the published version.

PUT /pages/<id>— Update page content. This endpoint handles both manual saves and autosave operations. The content field stores Slate’s JSON document tree.

DELETE /pages/<id>— Remove a page and re-number remaining pages to close the position gap.

Genres and readings

GET /genres/— Return the full genre list. Public endpoint, no authentication required.

GET /readings/— List notebooks the current user follows.

POST /readings/— Follow a notebook. Creates a Reading record linking the current user to the notebook.

DELETE /readings/— Unfollow a notebook. Removes the Reading record.

GET /search?q=...— Perform text search across notebooks and pages using SQL LIKE queries. Returns matching works ordered by relevance (approximate). This is sufficient for small datasets; a dedicated search index would be the next step for scale.


The domain model

SQLAlchemy models live in app/models/. Each model class maps to a database table.

class User(db.Model): __tablename__ = 'user' user_id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(50), unique=True, nullable=False) email = db.Column(db.String(100), unique=True, nullable=False) password = db.Column(db.String(255), nullable=False) user_created = db.Column(db.DateTime, default=datetime.utcnow) notebooks = db.relationship('Notebook', backref='author', lazy=True) class Notebook(db.Model): __tablename__ = 'notebook' notebook_id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey('user.user_id'), nullable=False) genre_id = db.Column(db.Integer, db.ForeignKey('genre.genre_id'), nullable=False) title = db.Column(db.String(100), nullable=False) resume = db.Column(db.Text) pages = db.relationship('Page', backref='notebook', lazy=True, order_by='Page.position') class Page(db.Model): __tablename__ = 'page' page_id = db.Column(db.Integer, primary_key=True) notebook_id = db.Column(db.Integer, db.ForeignKey('notebook.notebook_id')) title = db.Column(db.String(100)) content = db.Column(db.JSON) position = db.Column(db.Integer)

Page content is stored as JSON, which maps directly to Slate’s document format. SQLAlchemy’s db.JSON type handles serialization transparently.


Marshmallow schemas

Every request body and response payload goes through a Marshmallow schema.

class NotebookSchema(ma.Schema): notebook_id = fields.Int(dump_only=True) user_id = fields.Int(required=True) genre_id = fields.Int(required=True) title = fields.Str(required=True, validate=validate.Length(min=1, max=100)) resume = fields.Str() notebook_schema = NotebookSchema() notebooks_schema = NotebookSchema(many=True)

On POST requests, the resource calls notebook_schema.load(request.json), which validates the input and raises a ValidationError if anything is wrong. Flask-RESTful converts that into a 400 response with the validation messages. On GET responses, notebook_schema.dump(notebook) serializes the model instance to a dict, filtering out fields marked load_only and adding computed properties. The pattern is consistent across all resources.


A full request walkthrough

Following a single request from Nginx to the database response makes the pieces concrete.

A POST to /api/notebooks/ arrives at Nginx. The config strips the /api prefix and forwards to flask:5000/notebooks/. Flask-JWT-Extended’s @jwt_required decorator intercepts first, reads the Authorization header, decodes the token, checks the Redis blacklist via the token_in_blacklist_loader callback, and either raises a 401 or sets the JWT context.

class NotebookList(Resource): @jwt_required def post(self): user_id = get_jwt_identity() data = notebook_schema.load(request.json) notebook = Notebook(user_id=user_id, **data) db.session.add(notebook) db.session.commit() return notebook_schema.dump(notebook), 201

SQLAlchemy sends the INSERT through PyMySQL to MySQL Router on port 6446. The Router forwards to the primary node. The commit completes, SQLAlchemy populates notebook.notebook_id from the AUTO_INCREMENT, and the schema serializes the response.


Page position management

When a page is deleted from the middle of a notebook, the remaining pages need their positions compacted.

@staticmethod def reorder(notebook_id, deleted_position): pages = Page.query.filter( Page.notebook_id == notebook_id, Page.position > deleted_position ).all() for page in pages: page.position -= 1 db.session.commit()

The delete resource calls Page.reorder() after the delete. This keeps positions contiguous. The frontend can use position as a stable sort key without checking for gaps.


Storage integrations

MinIO handles all binary assets. Flask never writes files to disk. The MinIO client streams the file directly from the request into object storage.

minio_client.put_object( bucket_name='works', object_name=f'{notebook_id}.jpg', data=image.stream, length=-1, part_size=10 * 1024 * 1024, content_type=image.content_type )

The Flask service validates MIME type and file size on the server before uploading. After the MinIO write succeeds, metadata (object key, MIME type, size) goes into MySQL inside a transaction. If the database write fails, the object is marked for cleanup. No dangling references land in the relational store.

Redis has one job: the JWT blacklist. On logout, the resource stores the JWT ID with a TTL slightly longer than the token’s remaining lifetime.

jti = get_raw_jwt()['jti'] rc.set(jti, jti, ex=int(ACCESS_EXPIRES.total_seconds() * 1.2))

The TTL is ACCESS_EXPIRES * 1.2 (18 minutes for a 15-minute token) to account for clock drift between services. Once the token would have expired naturally, Redis deletes the key automatically. The blacklist never needs manual cleanup.


Operational reliability patterns

Several patterns improve reliability in practice.

SQLAlchemy connection pooling is tuned to match the number of Gunicorn workers, preventing connection exhaustion under load. The service is designed to be stateless with respect to file storage, which means Flask containers can be restarted or scaled horizontally without data loss. The Marshmallow validation layer ensures malformed requests fail fast at the boundary, before touching the database. Input file validation verifies both MIME type and file size on the server side, not just the client side.

For production hardening, the architecture anticipates health endpoints for readiness and liveness checks, idempotency keys for retryable writes, and Prometheus metrics on Flask routes via a middleware layer. These aren’t implemented in the current repository but the structure makes them straightforward additions.