Flask #

Flask adalah micro web framework Python yang memberi kebebasan penuh kepada developer — tidak ada ORM bawaan, tidak ada struktur direktori yang dipaksakan, tidak ada komponen yang wajib dipakai. Kamu merakit sendiri apa yang dibutuhkan. Kesederhanaan ini membuatnya ideal untuk REST API ringan, microservice, prototype cepat, dan aplikasi dengan kebutuhan yang sangat spesifik. Kunci memahami Flask adalah memahami tiga konsep intinya: routing dengan decorator, request context, dan application factory pattern yang memungkinkan konfigurasi fleksibel dan testing yang bersih.

Instalasi #

pip install flask flask-sqlalchemy python-dotenv

Application Factory Pattern #

Application factory adalah pola standar Flask untuk proyek yang serius — membuat instance Flask di dalam fungsi, bukan di level modul global. Ini memudahkan testing dan konfigurasi multi-environment.

# app/__init__.py

from flask import Flask
from .config import config_by_name

def create_app(config_name: str = "development") -> Flask:
    app = Flask(__name__)
    app.config.from_object(config_by_name[config_name])

    # Inisialisasi extensions
    from .extensions import db, migrate
    db.init_app(app)
    migrate.init_app(app, db)

    # Daftarkan blueprint
    from .produk  import produk_bp
    from .auth    import auth_bp
    from .api     import api_bp

    app.register_blueprint(produk_bp,  url_prefix="/produk")
    app.register_blueprint(auth_bp,    url_prefix="/auth")
    app.register_blueprint(api_bp,     url_prefix="/api/v1")

    # Daftarkan error handler
    register_error_handlers(app)

    return app

def register_error_handlers(app: Flask) -> None:
    from flask import jsonify

    @app.errorhandler(400)
    def bad_request(e):
        return jsonify(error="Bad Request", message=str(e)), 400

    @app.errorhandler(404)
    def not_found(e):
        return jsonify(error="Not Found", message=str(e)), 404

    @app.errorhandler(500)
    def internal_error(e):
        return jsonify(error="Internal Server Error"), 500
# app/config.py

import os
from dotenv import load_dotenv

load_dotenv()

class BaseConfig:
    SECRET_KEY          = os.getenv("SECRET_KEY", "dev-secret-ganti-di-produksi")
    SQLALCHEMY_TRACK_MODIFICATIONS = False
    JSON_SORT_KEYS      = False

class DevelopmentConfig(BaseConfig):
    DEBUG               = True
    SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL", "sqlite:///dev.db")

class TestingConfig(BaseConfig):
    TESTING             = True
    SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"
    WTF_CSRF_ENABLED    = False

class ProductionConfig(BaseConfig):
    DEBUG               = False
    SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL")

    # ANTI-PATTERN: fallback ke dev config di produksi
    # assert os.getenv("DATABASE_URL"), "DATABASE_URL harus di-set di produksi!"

config_by_name = {
    "development": DevelopmentConfig,
    "testing":     TestingConfig,
    "production":  ProductionConfig,
}
# app/extensions.py

from flask_sqlalchemy import SQLAlchemy
from flask_migrate    import Migrate

db      = SQLAlchemy()
migrate = Migrate()
# wsgi.py -- entry point

import os
from app import create_app

app = create_app(os.getenv("FLASK_ENV", "development"))

if __name__ == "__main__":
    app.run()
# Jalankan dengan Gunicorn di produksi
gunicorn wsgi:app --workers 4 --bind 0.0.0.0:8000

# Development
flask --app wsgi run --debug

Model dengan Flask-SQLAlchemy #

# app/models.py

from .extensions import db
from datetime    import datetime, timezone
from werkzeug.security import generate_password_hash, check_password_hash

class TimestampMixin:
    """Mixin untuk kolom created_at dan updated_at otomatis."""
    dibuat_pada = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
    diubah_pada = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
                            onupdate=lambda: datetime.now(timezone.utc), nullable=False)

class Kategori(TimestampMixin, db.Model):
    __tablename__ = "kategori"

    id   = db.Column(db.Integer, primary_key=True)
    nama = db.Column(db.String(100), nullable=False, unique=True)
    slug = db.Column(db.String(120), nullable=False, unique=True)

    produk = db.relationship("Produk", back_populates="kategori", lazy="dynamic")

    def to_dict(self) -> dict:
        return {"id": self.id, "nama": self.nama, "slug": self.slug}

    def __repr__(self):
        return f"<Kategori {self.nama}>"

class Produk(TimestampMixin, db.Model):
    __tablename__ = "produk"

    id          = db.Column(db.Integer, primary_key=True)
    nama        = db.Column(db.String(200), nullable=False)
    slug        = db.Column(db.String(220), nullable=False, unique=True)
    deskripsi   = db.Column(db.Text, default="")
    harga       = db.Column(db.Numeric(15, 2), nullable=False)
    stok        = db.Column(db.Integer, default=0)
    aktif       = db.Column(db.Boolean, default=True)
    kategori_id = db.Column(db.Integer, db.ForeignKey("kategori.id"), nullable=True)

    kategori = db.relationship("Kategori", back_populates="produk")

    def to_dict(self) -> dict:
        return {
            "id":           self.id,
            "nama":         self.nama,
            "slug":         self.slug,
            "deskripsi":    self.deskripsi,
            "harga":        float(self.harga),
            "stok":         self.stok,
            "aktif":        self.aktif,
            "kategori":     self.kategori.to_dict() if self.kategori else None,
            "dibuat_pada":  self.dibuat_pada.isoformat(),
        }

    def __repr__(self):
        return f"<Produk {self.nama}>"

class Pengguna(TimestampMixin, db.Model):
    __tablename__ = "pengguna"

    id            = db.Column(db.Integer, primary_key=True)
    nama          = db.Column(db.String(100), nullable=False)
    email         = db.Column(db.String(150), nullable=False, unique=True)
    password_hash = db.Column(db.String(256), nullable=False)
    aktif         = db.Column(db.Boolean, default=True)

    def set_password(self, password: str) -> None:
        self.password_hash = generate_password_hash(password)

    def check_password(self, password: str) -> bool:
        return check_password_hash(self.password_hash, password)

    def to_dict(self) -> dict:
        return {"id": self.id, "nama": self.nama, "email": self.email}

Blueprint dan Routing #

Blueprint memungkinkan pemecahan aplikasi besar menjadi modul yang terpisah, masing-masing dengan route, template, dan static file sendiri.

# app/produk/__init__.py
from flask import Blueprint
produk_bp = Blueprint("produk", __name__, template_folder="templates")
from . import views   # import views agar route terdaftar
# app/produk/views.py

from flask              import render_template, request, redirect, url_for, flash, abort
from ..extensions       import db
from ..models           import Produk, Kategori
from . import produk_bp

@produk_bp.route("/")
def daftar():
    halaman     = request.args.get("halaman", 1, type=int)
    per_halaman = 12
    q           = request.args.get("q", "")

    query = Produk.query.filter_by(aktif=True)
    if q:
        query = query.filter(Produk.nama.ilike(f"%{q}%"))

    pagination  = query.order_by(Produk.dibuat_pada.desc()).paginate(
        page=halaman, per_page=per_halaman, error_out=False
    )

    return render_template(
        "produk/daftar.html",
        produk_list=pagination.items,
        pagination=pagination,
        q=q,
        kategori_list=Kategori.query.order_by(Kategori.nama).all()
    )

@produk_bp.route("/<slug>")
def detail(slug: str):
    produk = Produk.query.filter_by(slug=slug, aktif=True).first_or_404()
    return render_template("produk/detail.html", produk=produk)

@produk_bp.route("/tambah", methods=["GET", "POST"])
def tambah():
    if request.method == "POST":
        nama  = request.form.get("nama", "").strip()
        harga = request.form.get("harga", type=float)

        if not nama or not harga:
            flash("Nama dan harga wajib diisi.", "error")
            return redirect(url_for("produk.tambah"))

        from slugify import slugify
        produk = Produk(nama=nama, harga=harga, slug=slugify(nama))
        db.session.add(produk)
        db.session.commit()
        flash(f"Produk '{nama}' berhasil ditambahkan.", "success")
        return redirect(url_for("produk.detail", slug=produk.slug))

    return render_template("produk/form.html", produk=None)

REST API dengan Blueprint #

# app/api/__init__.py
from flask import Blueprint
api_bp = Blueprint("api", __name__)
from . import produk_api
# app/api/produk_api.py

from flask          import request, jsonify, abort
from ..extensions   import db
from ..models       import Produk, Kategori
from . import api_bp

@api_bp.route("/produk", methods=["GET"])
def list_produk():
    halaman     = request.args.get("halaman", 1, type=int)
    per_halaman = request.args.get("per_halaman", 20, type=int)
    per_halaman = min(per_halaman, 100)   # batasi maks 100

    query = Produk.query.filter_by(aktif=True)

    q = request.args.get("q")
    if q:
        query = query.filter(Produk.nama.ilike(f"%{q}%"))

    pagination = query.order_by(Produk.dibuat_pada.desc()).paginate(
        page=halaman, per_page=per_halaman, error_out=False
    )

    return jsonify({
        "total":       pagination.total,
        "halaman":     halaman,
        "per_halaman": per_halaman,
        "data":        [p.to_dict() for p in pagination.items]
    })

@api_bp.route("/produk/<int:produk_id>", methods=["GET"])
def get_produk(produk_id: int):
    produk = Produk.query.get_or_404(produk_id)
    return jsonify(produk.to_dict())

@api_bp.route("/produk", methods=["POST"])
def buat_produk():
    data = request.get_json(silent=True)

    # ANTI-PATTERN: tidak validasi input
    # produk = Produk(**data)  # ✗ -- bisa inject field apapun

    # BENAR: whitelist field yang diizinkan
    if not data:
        return jsonify(error="Body JSON wajib disertakan"), 400

    nama  = data.get("nama", "").strip()
    harga = data.get("harga")

    if not nama:
        return jsonify(error="Field 'nama' wajib diisi"), 422
    if harga is None or float(harga) <= 0:
        return jsonify(error="Field 'harga' wajib lebih dari 0"), 422

    from slugify import slugify
    produk = Produk(
        nama=nama,
        slug=slugify(nama),
        harga=harga,
        deskripsi=data.get("deskripsi", ""),
        stok=data.get("stok", 0),
        kategori_id=data.get("kategori_id")
    )
    db.session.add(produk)
    db.session.commit()

    return jsonify(produk.to_dict()), 201

@api_bp.route("/produk/<int:produk_id>", methods=["PATCH"])
def update_produk(produk_id: int):
    produk = Produk.query.get_or_404(produk_id)
    data   = request.get_json(silent=True)

    if not data:
        return jsonify(error="Body JSON wajib disertakan"), 400

    # Update hanya field yang dikirim
    field_diizinkan = {"nama", "deskripsi", "harga", "stok", "aktif", "kategori_id"}
    for key, value in data.items():
        if key in field_diizinkan:
            setattr(produk, key, value)

    db.session.commit()
    return jsonify(produk.to_dict())

@api_bp.route("/produk/<int:produk_id>", methods=["DELETE"])
def hapus_produk(produk_id: int):
    produk = Produk.query.get_or_404(produk_id)
    db.session.delete(produk)
    db.session.commit()
    return "", 204

Request Hooks #

Request hooks memungkinkan kamu menjalankan kode sebelum atau sesudah setiap request — cocok untuk autentikasi, logging, dan manajemen koneksi.

# app/api/__init__.py atau di blueprint manapun

from flask import request, jsonify, g
import time

@api_bp.before_request
def before_api_request():
    g.start_time = time.time()   # simpan di g (request context)

    # Validasi Content-Type untuk request yang punya body
    if request.method in ("POST", "PUT", "PATCH"):
        if not request.is_json:
            return jsonify(error="Content-Type harus application/json"), 415

@api_bp.after_request
def after_api_request(response):
    durasi = (time.time() - g.get("start_time", time.time())) * 1000
    response.headers["X-Response-Time"] = f"{durasi:.1f}ms"
    response.headers["X-API-Version"]   = "1.0"
    return response

@api_bp.teardown_request
def teardown(exc):
    if exc:
        db.session.rollback()
    db.session.remove()

Template Jinja2 #

<!-- app/templates/base.html -->

<!DOCTYPE html>
<html lang="id">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>{% block title %}MyApp{% endblock %}</title>
</head>
<body>
  <nav>
    <a href="{{ url_for('produk.daftar') }}">Produk</a>
  </nav>

  {% with messages = get_flashed_messages(with_categories=true) %}
    {% for category, message in messages %}
      <div class="alert alert-{{ category }}">{{ message }}</div>
    {% endfor %}
  {% endwith %}

  <main>{% block content %}{% endblock %}</main>
</body>
</html>
<!-- app/templates/produk/daftar.html -->

{% extends "base.html" %}

{% block title %}Daftar Produk{% endblock %}

{% block content %}
<h1>Produk</h1>

<form method="get">
  <input name="q" value="{{ q }}" placeholder="Cari produk...">
  <button>Cari</button>
</form>

{% for produk in produk_list %}
  <div>
    <a href="{{ url_for('produk.detail', slug=produk.slug) }}">{{ produk.nama }}</a>
    <span>Rp{{ "{:,.0f}".format(produk.harga) }}</span>
    <span>Stok: {{ produk.stok }}</span>
  </div>
{% else %}
  <p>Tidak ada produk ditemukan.</p>
{% endfor %}

{# Pagination #}
{% if pagination.pages > 1 %}
  {% if pagination.has_prev %}
    <a href="{{ url_for('produk.daftar', halaman=pagination.prev_num, q=q) }}">← Sebelumnya</a>
  {% endif %}
  <span>{{ pagination.page }}/{{ pagination.pages }}</span>
  {% if pagination.has_next %}
    <a href="{{ url_for('produk.daftar', halaman=pagination.next_num, q=q) }}">Berikutnya →</a>
  {% endif %}
{% endif %}
{% endblock %}

Testing #

# tests/conftest.py

import pytest
from app        import create_app
from app.extensions import db as _db

@pytest.fixture(scope="session")
def app():
    app = create_app("testing")
    with app.app_context():
        _db.create_all()
        yield app
        _db.drop_all()

@pytest.fixture
def client(app):
    return app.test_client()

@pytest.fixture(autouse=True)
def db_session(app):
    """Rollback setiap transaksi setelah test selesai."""
    with app.app_context():
        connection = _db.engine.connect()
        transaction = connection.begin()
        _db.session.bind = connection

        yield _db.session

        _db.session.remove()
        transaction.rollback()
        connection.close()
# tests/test_api_produk.py

import json

def test_list_produk(client):
    response = client.get("/api/v1/produk")
    assert response.status_code == 200
    data = response.get_json()
    assert "data" in data
    assert "total" in data

def test_buat_produk(client):
    response = client.post(
        "/api/v1/produk",
        data=json.dumps({"nama": "Laptop Test", "harga": 15000000, "stok": 5}),
        content_type="application/json"
    )
    assert response.status_code == 201
    data = response.get_json()
    assert data["nama"]  == "Laptop Test"
    assert data["harga"] == 15000000.0
    assert "id" in data

def test_buat_produk_tanpa_nama(client):
    response = client.post(
        "/api/v1/produk",
        data=json.dumps({"harga": 10000}),
        content_type="application/json"
    )
    assert response.status_code == 422
    assert "nama" in response.get_json()["error"].lower()

def test_hapus_produk(client):
    # Buat dulu
    create_resp = client.post(
        "/api/v1/produk",
        data=json.dumps({"nama": "Produk Hapus", "harga": 5000}),
        content_type="application/json"
    )
    produk_id = create_resp.get_json()["id"]

    # Hapus
    delete_resp = client.delete(f"/api/v1/produk/{produk_id}")
    assert delete_resp.status_code == 204

    # Verifikasi sudah tidak ada
    get_resp = client.get(f"/api/v1/produk/{produk_id}")
    assert get_resp.status_code == 404

Kapan Memilih Flask vs Django vs FastAPI #

Pilih Flask jika:
  ✓ Butuh kontrol penuh atas arsitektur tanpa konvensi yang dipaksakan
  ✓ Membangun microservice kecil atau REST API sederhana
  ✓ Tim sudah familiar dengan Flask dan ekosistemnya
  ✓ Prototyping cepat atau aplikasi dengan kebutuhan sangat spesifik

Pilih Django jika:
  ✓ Aplikasi full-stack dengan banyak fitur (CMS, e-commerce, portal)
  ✓ Tim ingin konvensi dan struktur yang sudah terbukti
  ✓ Butuh admin interface, ORM, dan autentikasi bawaan
  ✓ Proyek jangka panjang dengan banyak developer

Pilih FastAPI jika:
  ✓ Membangun API modern dengan validasi otomatis dan dokumentasi OpenAPI
  ✓ Butuh performa async/await native
  ✓ Tim menggunakan type hints secara konsisten
  ✓ ML serving, microservice, atau API dengan skema yang ketat

Ringkasan #

  • Application Factory Pattern — buat instance Flask di dalam fungsi create_app(), bukan di level modul; ini memungkinkan multiple konfigurasi dan testing yang bersih.
  • Blueprint untuk modularitas — pisahkan fitur ke blueprint terpisah; setiap blueprint bisa punya URL prefix, template folder, dan error handler sendiri.
  • request.get_json(silent=True) — gunakan agar tidak raise exception jika body bukan JSON valid; cek None secara eksplisit setelahnya.
  • Whitelist field saat update — jangan lakukan Model(**request.json) langsung; whitelist field yang diizinkan untuk mencegah mass assignment vulnerability.
  • first_or_404() dan get_or_404() — gunakan daripada .first() atau .get() di route yang harus mengembalikan 404 jika tidak ditemukan.
  • g untuk request context — simpan data per-request (timing, user saat ini) di flask.g; otomatis dibersihkan setelah setiap request.
  • before_request dan after_request — gunakan untuk autentikasi, validasi Content-Type, dan menambahkan response header secara terpusat.
  • to_dict() di model — definisikan metode serialisasi di model agar response JSON konsisten dan mudah dikontrol field mana yang disertakan.
  • Konfigurasi berbasis kelas — gunakan DevelopmentConfig, TestingConfig, ProductionConfig yang terpisah; baca nilai sensitif dari environment variable.
  • Testing dengan rollback — gunakan fixture yang rollback transaksi setelah setiap test agar database selalu dalam kondisi bersih tanpa perlu drop/create ulang.

← Sebelumnya: FastAPI   Berikutnya: PyTest →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact