From b9b8aece29701d57e1eef510bbc8e6edd1944db6 Mon Sep 17 00:00:00 2001 From: bofandra Date: Sat, 24 Oct 2026 06:50:10 +0700 Subject: [PATCH] Add optional FastAPI Flask Django OOP web API examples --- .github/workflows/web-framework-examples.yml | 43 +++++ README.md | 1 + examples/web-frameworks/README.md | 172 ++++++++++++++++++ examples/web-frameworks/django/.gitignore | 2 + examples/web-frameworks/django/README.md | 59 ++++++ .../web-frameworks/django/books/__init__.py | 1 + examples/web-frameworks/django/books/apps.py | 6 + .../django/books/migrations/0001_initial.py | 18 ++ .../django/books/migrations/__init__.py | 1 + .../web-frameworks/django/books/models.py | 11 ++ examples/web-frameworks/django/books/tests.py | 60 ++++++ examples/web-frameworks/django/books/urls.py | 9 + examples/web-frameworks/django/books/views.py | 76 ++++++++ .../web-frameworks/django/config/__init__.py | 1 + examples/web-frameworks/django/config/asgi.py | 6 + .../web-frameworks/django/config/settings.py | 32 ++++ examples/web-frameworks/django/config/urls.py | 6 + examples/web-frameworks/django/config/wsgi.py | 6 + examples/web-frameworks/django/manage.py | 12 ++ .../web-frameworks/django/requirements.txt | 1 + examples/web-frameworks/fastapi/README.md | 53 ++++++ examples/web-frameworks/fastapi/app.py | 101 ++++++++++ .../web-frameworks/fastapi/requirements.txt | 3 + .../web-frameworks/fastapi/tests/test_api.py | 56 ++++++ examples/web-frameworks/flask/README.md | 50 +++++ examples/web-frameworks/flask/app.py | 103 +++++++++++ .../web-frameworks/flask/requirements.txt | 1 + .../web-frameworks/flask/tests/test_api.py | 54 ++++++ 28 files changed, 944 insertions(+) create mode 100644 .github/workflows/web-framework-examples.yml create mode 100644 examples/web-frameworks/README.md create mode 100644 examples/web-frameworks/django/.gitignore create mode 100644 examples/web-frameworks/django/README.md create mode 100644 examples/web-frameworks/django/books/__init__.py create mode 100644 examples/web-frameworks/django/books/apps.py create mode 100644 examples/web-frameworks/django/books/migrations/0001_initial.py create mode 100644 examples/web-frameworks/django/books/migrations/__init__.py create mode 100644 examples/web-frameworks/django/books/models.py create mode 100644 examples/web-frameworks/django/books/tests.py create mode 100644 examples/web-frameworks/django/books/urls.py create mode 100644 examples/web-frameworks/django/books/views.py create mode 100644 examples/web-frameworks/django/config/__init__.py create mode 100644 examples/web-frameworks/django/config/asgi.py create mode 100644 examples/web-frameworks/django/config/settings.py create mode 100644 examples/web-frameworks/django/config/urls.py create mode 100644 examples/web-frameworks/django/config/wsgi.py create mode 100644 examples/web-frameworks/django/manage.py create mode 100644 examples/web-frameworks/django/requirements.txt create mode 100644 examples/web-frameworks/fastapi/README.md create mode 100644 examples/web-frameworks/fastapi/app.py create mode 100644 examples/web-frameworks/fastapi/requirements.txt create mode 100644 examples/web-frameworks/fastapi/tests/test_api.py create mode 100644 examples/web-frameworks/flask/README.md create mode 100644 examples/web-frameworks/flask/app.py create mode 100644 examples/web-frameworks/flask/requirements.txt create mode 100644 examples/web-frameworks/flask/tests/test_api.py diff --git a/.github/workflows/web-framework-examples.yml b/.github/workflows/web-framework-examples.yml new file mode 100644 index 0000000..560968e --- /dev/null +++ b/.github/workflows/web-framework-examples.yml @@ -0,0 +1,43 @@ +name: Test optional web framework examples + +on: + push: + paths: + - "examples/web-frameworks/**" + - ".github/workflows/web-framework-examples.yml" + pull_request: + paths: + - "examples/web-frameworks/**" + - ".github/workflows/web-framework-examples.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + name: Python 3.12 / ${{ matrix.framework }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + framework: [fastapi, flask, django] + defaults: + run: + working-directory: examples/web-frameworks/${{ matrix.framework }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install dependencies + run: python -m pip install -r requirements.txt + - name: Test FastAPI and Flask + if: matrix.framework != 'django' + run: python -m unittest discover -s tests -v + - name: Test Django and migrations + if: matrix.framework == 'django' + run: | + python manage.py check + python manage.py makemigrations --check --dry-run + python manage.py test books -v 2 diff --git a/README.md b/README.md index 699d444..4a262a2 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Primary lab environment: **Google Colab** - Ready to run code: [Open notebooks in Google Colab](COLAB.md) - Need the course readings: [Reading & Reference Access Guide](READING_GUIDE.md) - Want the full pathway: [Course Map](COURSE_MAP.md) +- Optional after learning OOP: [FastAPI, Flask, and Django mini-projects](examples/web-frameworks/README.md) No enrollment or fixed calendar is required. diff --git a/examples/web-frameworks/README.md b/examples/web-frameworks/README.md new file mode 100644 index 0000000..926a839 --- /dev/null +++ b/examples/web-frameworks/README.md @@ -0,0 +1,172 @@ +# Bonus praktikum: FastAPI, Flask, dan Django + +> **Materi tambahan (opsional)** untuk pembelajar yang sudah memahami OOP. +> Web framework, HTTP/REST, database, dan dependency eksternal **bukan** +> capaian wajib atau bahan asesmen modul 0–16. Lihat [Source Boundary](../../SOURCE_BOUNDARY.md). + +Ketiga proyek menjalankan **Book Management REST API** yang sama sehingga +perbandingan berfokus pada cara setiap framework menerima HTTP request, +memproses business logic, dan menghasilkan HTTP response. + +## Tujuan pembelajaran + +- Menghubungkan class, object, encapsulation, state, dan responsibility dengan backend web. +- Melihat perbedaan routing, request parsing, validation, persistence, dan pengujian. +- Memahami bahwa framework bukan pengganti desain OOP. + +## Struktur + +```text +examples/web-frameworks/ +├── README.md # Panduan komparatif ini +├── fastapi/ # Type hints, Pydantic, OpenAPI, in-memory repository +├── flask/ # Minimal routing, validasi manual, in-memory repository +└── django/ # Model ORM, migration, SQLite, view JSON native +``` + +## API yang dibandingkan (ketiga proyek sama) + +| Method | Endpoint | Hasil | Status sukses | +|---|---|---|---| +| GET | /books | Semua buku | 200 | +| GET | /books/{id} | Satu buku | 200 | +| POST | /books | Membuat buku | 201 | +| PUT | /books/{id} | Mengubah seluruh field buku | 200 | +| DELETE | /books/{id} | Menghapus buku | 204 | + +Format input untuk POST dan PUT: + +```json +{"title": "Clean Code", "author": "Robert C. Martin"} +``` + +Format objek output: + +```json +{"id": 1, "title": "Clean Code", "author": "Robert C. Martin"} +``` + +Seluruh field `title` dan `author` wajib berupa teks yang tidak kosong +(setelah whitespace dibuang), maksimal 120 karakter. ID dibuat server. +Respons input tidak valid adalah 422 (FastAPI) atau 400 (Flask/Django). +ID tidak ditemukan menghasilkan 404. + +## Cara cepat menjalankan + +Gunakan **Python 3.12+**. Jalankan dari direktori proyek masing-masing, +dan buat virtual environment terpisah agar dependensi tidak saling bertabrakan. + +**FastAPI** — [baca panduan](fastapi/README.md): + +```bash +cd examples/web-frameworks/fastapi +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +python -m pip install -r requirements.txt +uvicorn app:app --reload --port 8001 +``` + +Buka http://127.0.0.1:8001/docs untuk dokumentasi interaktif. + +**Flask** — [baca panduan](flask/README.md): + +```bash +cd examples/web-frameworks/flask +python -m venv .venv +source .venv/bin/activate +python -m pip install -r requirements.txt +flask --app app run --debug --port 8002 +``` + +**Django** — [baca panduan](django/README.md): + +```bash +cd examples/web-frameworks/django +python -m venv .venv +source .venv/bin/activate +python -m pip install -r requirements.txt +python manage.py migrate +python manage.py runserver 8003 +``` + +Di terminal terpisah, uji salah satu server (ganti port 8001 menjadi 8002/8003): + +```bash +curl -i http://127.0.0.1:8001/books +curl -i -X POST http://127.0.0.1:8001/books \ + -H "Content-Type: application/json" \ + -d '{"title":"Clean Code","author":"Robert C. Martin"}' +curl -i http://127.0.0.1:8001/books/1 +curl -i -X PUT http://127.0.0.1:8001/books/1 \ + -H "Content-Type: application/json" \ + -d '{"title":"Refactoring","author":"Martin Fowler"}' +curl -i -X DELETE http://127.0.0.1:8001/books/1 +``` + +### Menjalankan tes + +Jalankan dari direktori tiap proyek, setelah memasang `requirements.txt`: + +```bash +# FastAPI / Flask +python -m unittest discover -s tests -v + +# Django +python manage.py test books -v 2 +``` + +GitHub Actions juga menjalankan ketiga suite melalui +[`web-framework-examples.yml`](../../.github/workflows/web-framework-examples.yml). + +## Perbandingan arsitektur + +```text +Client (curl/browser) + | + v +HTTP router / endpoint <-- FastAPI / Flask / Django + | + v +Business objects & rules <-- Book / BookRepository atau Book ORM + | + v +Storage <-- dict in-memory atau SQLite + | + v +JSON response + HTTP status +``` + +| Aspek | FastAPI | Flask | Django | +|---|---|---|---| +| Pemetaan URL | Decorator `@app.get/post/put/delete` | Decorator `@app.get/post/put/delete` | `urls.py` | +| Validasi input | `Pydantic` | Manual pada handler | Manual pada handler (tanpa Django REST Framework) | +| Model/domain | Dataclass `Book` | Dataclass `Book` | `models.Model` | +| Penyimpanan contoh | Dictionary dalam `BookRepository` | Dictionary dalam `BookRepository` | SQLite melalui ORM & migration | +| Dokumentasi OpenAPI | Otomatis di `/docs` | Tidak otomatis | Tidak otomatis | +| Konsep OOP | Composition: handler memakai repository | Composition: handler memakai repository | Model adalah class berbasis ORM | + +**Catatan tentang keadilan perbandingan:** perbedaan penyimpanan disengaja. +FastAPI/Flask memakai state proses yang hilang saat server restart; Django +memakai SQLite yang bertahan setelah restart. Karena itu contoh ini **bukan** +benchmark performa maupun rekomendasi arsitektur produksi. + +## Tugas praktikum lanjutan (opsional) + +1. Tambahkan atribut `year` pada tiga implementasi dan ubah pengujiannya. +2. Jelaskan perbedaan validasi FastAPI, Flask, dan Django. +3. Ubah Flask/FastAPI agar menyimpan data ke database; bandingkan tanggung jawab handler. +4. Tambahkan autentikasi, otorisasi, rate limiting, dan konfigurasi aman jika proyek dikembangkan ke produksi. + +## Peringatan keamanan dan operasional + +Semua contoh disediakan untuk **belajar di lokal**: tanpa autentikasi, tanpa +otorisasi, tanpa pagination, tanpa audit log, dan tanpa deployment hardening. +Penggunaan untuk produksi memerlukan konfigurasi rahasia dari environment, +penanganan konkurensi/transaksi, database yang sesuai, dan proteksi request +(termasuk CSRF bila menggunakan autentikasi berbasis cookie). + +## Bacaan dokumentasi resmi + +- FastAPI: https://fastapi.tiangolo.com/ +- Flask: https://flask.palletsprojects.com/ +- Django: https://docs.djangoproject.com/ diff --git a/examples/web-frameworks/django/.gitignore b/examples/web-frameworks/django/.gitignore new file mode 100644 index 0000000..c7707c0 --- /dev/null +++ b/examples/web-frameworks/django/.gitignore @@ -0,0 +1,2 @@ +db.sqlite3 +db.sqlite3-journal diff --git a/examples/web-frameworks/django/README.md b/examples/web-frameworks/django/README.md new file mode 100644 index 0000000..0a534d7 --- /dev/null +++ b/examples/web-frameworks/django/README.md @@ -0,0 +1,59 @@ +# Django: Book Management REST API + +Django memakai `Book(models.Model)` yang sekaligus mendeskripsikan data +dan menjadi pintu masuk **ORM**. Data contoh tersimpan dalam **SQLite**. +Untuk perbandingan langsung dengan FastAPI/Flask, HTTP API dibuat memakai +**Django native views**, tanpa Django REST Framework. + +## Arsitektur + +```text +HTTP request + -> config/urls.py -> books/urls.py + -> books/views.py (JSON parsing & validasi) + -> books/models.py: Book(models.Model) + -> Django ORM -> SQLite (db.sqlite3) + -> JsonResponse() +``` + +**Konsep OOP:** `Book` adalah subclass dari `models.Model` +(**inheritance**); instansinya menggabungkan state dan behavior seperti +`save()` / `delete()`. Views memakai objek ORM, bukan menulis SQL manual. + +## Menjalankan (Python 3.12+) + +```bash +cd examples/web-frameworks/django +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +python -m pip install -r requirements.txt +python manage.py migrate +python manage.py runserver 8003 +``` + +- API: http://127.0.0.1:8003/books +- Pengujian: `python manage.py test books -v 2` (database tes terisolasi). +- Cek perubahan model: `python manage.py makemigrations --check --dry-run`. + +Contoh: + +```bash +curl -i -X POST http://127.0.0.1:8003/books \ + -H "Content-Type: application/json" \ + -d '{"title":"Clean Code","author":"Robert C. Martin"}' +curl -i http://127.0.0.1:8003/books/1 +``` + +## Apa yang perlu diperhatikan? + +- `manage.py migrate` harus dijalankan sebelum memakai API. +- Model menyatakan field; migrasi `0001_initial.py` membuat tabel. +- Validasi JSON diprogram manual; tanpa DRF tidak ada serializer otomatis + dan error input contoh ini menghasilkan **400**. +- Django mendukung fitur yang lebih luas daripada yang ditampilkan oleh + proyek mini ini, seperti autentikasi, permissions, forms, admin, dan ORM. +- View `POST/PUT/DELETE` di sini menggunakan `csrf_exempt` **hanya untuk + demonstrasi JSON API tanpa login/cookie**. Jangan memakai pendekatan ini + untuk endpoint produksi yang mengandalkan sesi/cookie. +- `SECRET_KEY`, `DEBUG=True`, dan SQLite di sini untuk lokal saja; + bukan konfigurasi deployment. diff --git a/examples/web-frameworks/django/books/__init__.py b/examples/web-frameworks/django/books/__init__.py new file mode 100644 index 0000000..8ed6b26 --- /dev/null +++ b/examples/web-frameworks/django/books/__init__.py @@ -0,0 +1 @@ +# Books Django app. diff --git a/examples/web-frameworks/django/books/apps.py b/examples/web-frameworks/django/books/apps.py new file mode 100644 index 0000000..66802e6 --- /dev/null +++ b/examples/web-frameworks/django/books/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class BooksConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "books" diff --git a/examples/web-frameworks/django/books/migrations/0001_initial.py b/examples/web-frameworks/django/books/migrations/0001_initial.py new file mode 100644 index 0000000..aec6a7c --- /dev/null +++ b/examples/web-frameworks/django/books/migrations/0001_initial.py @@ -0,0 +1,18 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Book", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("title", models.CharField(max_length=120)), + ("author", models.CharField(max_length=120)), + ], + ) + ] diff --git a/examples/web-frameworks/django/books/migrations/__init__.py b/examples/web-frameworks/django/books/migrations/__init__.py new file mode 100644 index 0000000..e0297a4 --- /dev/null +++ b/examples/web-frameworks/django/books/migrations/__init__.py @@ -0,0 +1 @@ +# Django database migrations. diff --git a/examples/web-frameworks/django/books/models.py b/examples/web-frameworks/django/books/models.py new file mode 100644 index 0000000..af7b5e2 --- /dev/null +++ b/examples/web-frameworks/django/books/models.py @@ -0,0 +1,11 @@ +"""A Django ORM class: combines domain data with database persistence.""" + +from django.db import models + + +class Book(models.Model): + title = models.CharField(max_length=120) + author = models.CharField(max_length=120) + + def __str__(self): + return f"{self.title} — {self.author}" diff --git a/examples/web-frameworks/django/books/tests.py b/examples/web-frameworks/django/books/tests.py new file mode 100644 index 0000000..2edf6d1 --- /dev/null +++ b/examples/web-frameworks/django/books/tests.py @@ -0,0 +1,60 @@ +import json + +from django.test import TestCase + +from .models import Book + + +class BookApiTests(TestCase): + def send_json(self, method, path, payload): + return getattr(self.client, method)( + path, + data=json.dumps(payload), + content_type="application/json", + ) + + def test_complete_crud(self): + self.assertEqual(self.client.get("/books").json(), []) + created = self.send_json( + "post", "/books", {"title": " Clean Code ", "author": " Robert C. Martin "} + ) + self.assertEqual(created.status_code, 201) + self.assertEqual( + created.json(), {"id": 1, "title": "Clean Code", "author": "Robert C. Martin"} + ) + self.assertEqual(self.client.get("/books/1").json(), created.json()) + self.assertEqual(Book.objects.count(), 1) + + updated = self.send_json( + "put", "/books/1", {"title": "Refactoring", "author": "Martin Fowler"} + ) + self.assertEqual(updated.status_code, 200) + self.assertEqual(updated.json()["title"], "Refactoring") + self.assertEqual(self.client.delete("/books/1").status_code, 204) + self.assertEqual(self.client.get("/books").json(), []) + + def test_input_validation(self): + for payload in [ + {"title": " ", "author": "Jane"}, + {"title": "Book", "author": ""}, + {"title": 123, "author": "Jane"}, + {"title": "x" * 121, "author": "Jane"}, + {"title": "Only title"}, + ]: + with self.subTest(payload=payload): + self.assertEqual(self.send_json("post", "/books", payload).status_code, 400) + self.assertEqual( + self.client.post("/books", data="not json", content_type="application/json").status_code, + 400, + ) + + def test_missing_book(self): + self.assertEqual(self.client.get("/books/9").status_code, 404) + self.assertEqual( + self.send_json("put", "/books/9", {"title": "X", "author": "Y"}).status_code, + 404, + ) + self.assertEqual(self.client.delete("/books/9").status_code, 404) + + def test_method_not_allowed(self): + self.assertEqual(self.client.post("/books/3").status_code, 405) diff --git a/examples/web-frameworks/django/books/urls.py b/examples/web-frameworks/django/books/urls.py new file mode 100644 index 0000000..3d4ace6 --- /dev/null +++ b/examples/web-frameworks/django/books/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from . import views + + +urlpatterns = [ + path("books", views.books_collection, name="books_collection"), + path("books/", views.book_detail, name="book_detail"), +] diff --git a/examples/web-frameworks/django/books/views.py b/examples/web-frameworks/django/books/views.py new file mode 100644 index 0000000..02965f4 --- /dev/null +++ b/examples/web-frameworks/django/books/views.py @@ -0,0 +1,76 @@ +"""Plain Django JSON views, without Django REST Framework.""" + +import json + +from django.http import HttpResponse, JsonResponse +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_http_methods + +from .models import Book + + +def serialize_book(book): + return {"id": book.pk, "title": book.title, "author": book.author} + + +def parse_book_input(request): + """Django without DRF: JSON parsing and validation are explicit.""" + if request.content_type != "application/json": + return None + try: + data = json.loads(request.body) + except (json.JSONDecodeError, UnicodeDecodeError): + return None + if not isinstance(data, dict): + return None + title, author = data.get("title"), data.get("author") + if not isinstance(title, str) or not isinstance(author, str): + return None + title, author = title.strip(), author.strip() + if not title or not author or len(title) > 120 or len(author) > 120: + return None + return title, author + + +# Teaching-only, unauthenticated JSON API. DO NOT use csrf_exempt for +# session/cookie-authenticated production endpoints. +@csrf_exempt +@require_http_methods(["GET", "POST"]) +def books_collection(request): + if request.method == "GET": + books = Book.objects.order_by("id") + return JsonResponse([serialize_book(b) for b in books], safe=False) + fields = parse_book_input(request) + if fields is None: + return JsonResponse( + {"error": "title and author must be non-empty strings (max 120 chars)"}, + status=400, + ) + book = Book.objects.create(title=fields[0], author=fields[1]) + return JsonResponse(serialize_book(book), status=201) + + +@csrf_exempt +@require_http_methods(["GET", "PUT", "DELETE"]) +def book_detail(request, book_id): + try: + book = Book.objects.get(pk=book_id) + except Book.DoesNotExist: + return JsonResponse({"error": "Book not found"}, status=404) + + if request.method == "GET": + return JsonResponse(serialize_book(book)) + + if request.method == "DELETE": + book.delete() + return HttpResponse(status=204) + + fields = parse_book_input(request) + if fields is None: + return JsonResponse( + {"error": "title and author must be non-empty strings (max 120 chars)"}, + status=400, + ) + book.title, book.author = fields + book.save(update_fields=["title", "author"]) + return JsonResponse(serialize_book(book)) diff --git a/examples/web-frameworks/django/config/__init__.py b/examples/web-frameworks/django/config/__init__.py new file mode 100644 index 0000000..c9bdf12 --- /dev/null +++ b/examples/web-frameworks/django/config/__init__.py @@ -0,0 +1 @@ +# Django project package. diff --git a/examples/web-frameworks/django/config/asgi.py b/examples/web-frameworks/django/config/asgi.py new file mode 100644 index 0000000..bc24bd4 --- /dev/null +++ b/examples/web-frameworks/django/config/asgi.py @@ -0,0 +1,6 @@ +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") +application = get_asgi_application() diff --git a/examples/web-frameworks/django/config/settings.py b/examples/web-frameworks/django/config/settings.py new file mode 100644 index 0000000..95310c0 --- /dev/null +++ b/examples/web-frameworks/django/config/settings.py @@ -0,0 +1,32 @@ +"""Development-only settings for the optional OOP course example.""" + +from pathlib import Path + + +BASE_DIR = Path(__file__).resolve().parent.parent +# NEVER use these example values in production. +SECRET_KEY = "development-only-change-this-key" +DEBUG = True +ALLOWED_HOSTS = ["127.0.0.1", "localhost", "testserver"] + +INSTALLED_APPS = [ + "django.contrib.contenttypes", + "books", +] +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", +] +ROOT_URLCONF = "config.urls" +TEMPLATES = [] +WSGI_APPLICATION = "config.wsgi.application" +ASGI_APPLICATION = "config.asgi.application" + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +USE_TZ = True diff --git a/examples/web-frameworks/django/config/urls.py b/examples/web-frameworks/django/config/urls.py new file mode 100644 index 0000000..decba9e --- /dev/null +++ b/examples/web-frameworks/django/config/urls.py @@ -0,0 +1,6 @@ +"""All book endpoints are mounted at /books (without a trailing slash).""" + +from django.urls import include, path + + +urlpatterns = [path("", include("books.urls"))] diff --git a/examples/web-frameworks/django/config/wsgi.py b/examples/web-frameworks/django/config/wsgi.py new file mode 100644 index 0000000..ccf67ba --- /dev/null +++ b/examples/web-frameworks/django/config/wsgi.py @@ -0,0 +1,6 @@ +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") +application = get_wsgi_application() diff --git a/examples/web-frameworks/django/manage.py b/examples/web-frameworks/django/manage.py new file mode 100644 index 0000000..ce57dbc --- /dev/null +++ b/examples/web-frameworks/django/manage.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python +"""Management entry point for the Django example.""" + +import os +import sys + + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) diff --git a/examples/web-frameworks/django/requirements.txt b/examples/web-frameworks/django/requirements.txt new file mode 100644 index 0000000..1bd097a --- /dev/null +++ b/examples/web-frameworks/django/requirements.txt @@ -0,0 +1 @@ +Django>=5.2,<6 diff --git a/examples/web-frameworks/fastapi/README.md b/examples/web-frameworks/fastapi/README.md new file mode 100644 index 0000000..304ed4a --- /dev/null +++ b/examples/web-frameworks/fastapi/README.md @@ -0,0 +1,53 @@ +# FastAPI: Book Management REST API + +FastAPI menonjolkan **type hints** dan validasi otomatis dengan **Pydantic**. +`BookInput` memvalidasi body JSON, `BookOutput` mendokumentasikan response, +dan `BookRepository` memegang state serta operasi penyimpanan. + +## Arsitektur + +```text +HTTP request + -> FastAPI routing (@app.get/post/put/delete) + -> Pydantic BookInput (validasi otomatis) + -> BookRepository (enkapsulasi state & business operation) + -> Book dataclass + -> JSON response tervalidasi oleh BookOutput +``` + +**Konsep OOP:** `Book` adalah objek data; `BookRepository` adalah class +pemilik state `_books`; route functions adalah client dari repository +(**composition**); `create_app` memungkinkan repository berbeda pada setiap tes. + +## Menjalankan (Python 3.12+) + +```bash +cd examples/web-frameworks/fastapi +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +python -m pip install -r requirements.txt +uvicorn app:app --reload --port 8001 +``` + +- API: http://127.0.0.1:8001/books +- Swagger UI: http://127.0.0.1:8001/docs +- OpenAPI JSON: http://127.0.0.1:8001/openapi.json +- Test: `python -m unittest discover -s tests -v` + +Contoh: + +```bash +curl -i -X POST http://127.0.0.1:8001/books \ + -H "Content-Type: application/json" \ + -d '{"title":"Clean Code","author":"Robert C. Martin"}' +curl -i http://127.0.0.1:8001/books/1 +``` + +## Apa yang perlu diperhatikan? + +- Input salah mendapat HTTP **422** tanpa menulis parser JSON manual. +- `response_model` membuat skema respons terdokumentasi otomatis. +- Contoh ini memakai handler sinkron (`def`): `async def` hanya perlu + ketika pekerjaan I/O-nya mendukung async, bukan sekadar demi tampilan. +- Penyimpanan **in-memory**, akan kosong lagi setelah restart, dan tidak aman + untuk banyak worker/proses. Ini bukan desain produksi. diff --git a/examples/web-frameworks/fastapi/app.py b/examples/web-frameworks/fastapi/app.py new file mode 100644 index 0000000..8b05e8d --- /dev/null +++ b/examples/web-frameworks/fastapi/app.py @@ -0,0 +1,101 @@ +"""FastAPI Book API: type-driven validation + explicit repository object.""" + +from dataclasses import asdict, dataclass +from typing import Annotated + +from fastapi import FastAPI, HTTPException, Response +from pydantic import BaseModel, StringConstraints + + +NonEmptyText = Annotated[ + str, StringConstraints(strip_whitespace=True, min_length=1, max_length=120) +] + + +class BookInput(BaseModel): + title: NonEmptyText + author: NonEmptyText + + +class BookOutput(BookInput): + id: int + + +@dataclass +class Book: + id: int + title: str + author: str + + +class BookRepository: + """Encapsulates book state; a demo-only, per-process data store.""" + + def __init__(self) -> None: + self._books: dict[int, Book] = {} + self._next_id = 1 + + def list(self) -> list[Book]: + return list(self._books.values()) + + def get(self, book_id: int) -> Book | None: + return self._books.get(book_id) + + def create(self, data: BookInput) -> Book: + book = Book(id=self._next_id, title=data.title, author=data.author) + self._books[book.id] = book + self._next_id += 1 + return book + + def update(self, book_id: int, data: BookInput) -> Book | None: + book = self.get(book_id) + if book is None: + return None + book.title = data.title + book.author = data.author + return book + + def delete(self, book_id: int) -> bool: + return self._books.pop(book_id, None) is not None + + +def create_app(repository: BookRepository | None = None) -> FastAPI: + """App factory makes each test use an isolated repository.""" + books = repository if repository is not None else BookRepository() + app = FastAPI(title="Book API - FastAPI", version="1.0.0") + + def find_or_404(book_id: int) -> Book: + book = books.get(book_id) + if book is None: + raise HTTPException(status_code=404, detail="Book not found") + return book + + @app.get("/books", response_model=list[BookOutput]) + def list_books() -> list[dict]: + return [asdict(book) for book in books.list()] + + @app.get("/books/{book_id}", response_model=BookOutput) + def get_book(book_id: int) -> dict: + return asdict(find_or_404(book_id)) + + @app.post("/books", response_model=BookOutput, status_code=201) + def create_book(data: BookInput) -> dict: + return asdict(books.create(data)) + + @app.put("/books/{book_id}", response_model=BookOutput) + def update_book(book_id: int, data: BookInput) -> dict: + book = books.update(book_id, data) + if book is None: + raise HTTPException(status_code=404, detail="Book not found") + return asdict(book) + + @app.delete("/books/{book_id}", status_code=204) + def delete_book(book_id: int) -> Response: + if not books.delete(book_id): + raise HTTPException(status_code=404, detail="Book not found") + return Response(status_code=204) + + return app + + +app = create_app() diff --git a/examples/web-frameworks/fastapi/requirements.txt b/examples/web-frameworks/fastapi/requirements.txt new file mode 100644 index 0000000..3c5f1b6 --- /dev/null +++ b/examples/web-frameworks/fastapi/requirements.txt @@ -0,0 +1,3 @@ +fastapi>=0.115,<1 +uvicorn>=0.30,<1 +httpx>=0.27,<1 diff --git a/examples/web-frameworks/fastapi/tests/test_api.py b/examples/web-frameworks/fastapi/tests/test_api.py new file mode 100644 index 0000000..4d8b0a6 --- /dev/null +++ b/examples/web-frameworks/fastapi/tests/test_api.py @@ -0,0 +1,56 @@ +import unittest + +from fastapi.testclient import TestClient + +from app import BookRepository, create_app + + +class BookApiTests(unittest.TestCase): + def setUp(self): + self.client = TestClient(create_app(BookRepository())) + + def test_complete_crud(self): + self.assertEqual(self.client.get("/books").json(), []) + created = self.client.post( + "/books", json={"title": " Clean Code ", "author": " Robert C. Martin "} + ) + self.assertEqual(created.status_code, 201) + self.assertEqual( + created.json(), {"id": 1, "title": "Clean Code", "author": "Robert C. Martin"} + ) + self.assertEqual(self.client.get("/books/1").json(), created.json()) + self.assertEqual(len(self.client.get("/books").json()), 1) + + updated = self.client.put( + "/books/1", json={"title": "Refactoring", "author": "Martin Fowler"} + ) + self.assertEqual(updated.status_code, 200) + self.assertEqual(updated.json()["title"], "Refactoring") + self.assertEqual(self.client.delete("/books/1").status_code, 204) + self.assertEqual(self.client.get("/books").json(), []) + + def test_input_validation(self): + for payload in [ + {"title": " ", "author": "Jane"}, + {"title": "Book", "author": ""}, + {"title": 123, "author": "Jane"}, + {"title": "x" * 121, "author": "Jane"}, + {"title": "Only title"}, + ]: + with self.subTest(payload=payload): + self.assertEqual(self.client.post("/books", json=payload).status_code, 422) + + def test_missing_book(self): + self.assertEqual(self.client.get("/books/9").status_code, 404) + self.assertEqual( + self.client.put("/books/9", json={"title": "X", "author": "Y"}).status_code, 404 + ) + self.assertEqual(self.client.delete("/books/9").status_code, 404) + + def test_openapi_is_generated(self): + self.assertEqual(self.client.get("/openapi.json").status_code, 200) + self.assertEqual(self.client.get("/docs").status_code, 200) + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/web-frameworks/flask/README.md b/examples/web-frameworks/flask/README.md new file mode 100644 index 0000000..df2e51d --- /dev/null +++ b/examples/web-frameworks/flask/README.md @@ -0,0 +1,50 @@ +# Flask: Book Management REST API + +Flask menyediakan routing dan utilitas HTTP dengan sedikit aturan bawaan. +Karena tidak memakai Pydantic, contoh ini memvalidasi body JSON **secara manual**. + +## Arsitektur + +```text +HTTP request + -> Flask decorator (@app.get/post/put/delete) + -> request.get_json() + validate_json_body() + -> BookRepository (enkapsulasi state) + -> Book dataclass + -> jsonify() + status code +``` + +**Konsep OOP:** `BookRepository` menyimpan state di `_books`. +Endpoint tidak mengakses dictionary secara langsung. Melalui +`create_app(repository)`, aplikasi dapat menerima repository alternatif +untuk tes (**composition / dependency injection sederhana**). + +## Menjalankan (Python 3.12+) + +```bash +cd examples/web-frameworks/flask +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +python -m pip install -r requirements.txt +flask --app app run --debug --port 8002 +``` + +- API: http://127.0.0.1:8002/books +- Test: `python -m unittest discover -s tests -v` + +Contoh: + +```bash +curl -i -X POST http://127.0.0.1:8002/books \ + -H "Content-Type: application/json" \ + -d '{"title":"Clean Code","author":"Robert C. Martin"}' +curl -i http://127.0.0.1:8002/books/1 +``` + +## Apa yang perlu diperhatikan? + +- Validasi disusun manual di `validate_json_body`; input salah mendapat **400**. +- Flask tidak otomatis menghasilkan Swagger/OpenAPI dalam contoh ini. +- `app.test_client()` digunakan untuk test HTTP tanpa menjalankan server. +- Penyimpanan **in-memory**, hilang saat restart dan tidak mendukung multiworker; + gunakan database/transaksi dan kontrol akses untuk aplikasi nyata. diff --git a/examples/web-frameworks/flask/app.py b/examples/web-frameworks/flask/app.py new file mode 100644 index 0000000..ca72ba8 --- /dev/null +++ b/examples/web-frameworks/flask/app.py @@ -0,0 +1,103 @@ +"""Flask Book API: explicit HTTP handling + object-oriented repository.""" + +from dataclasses import asdict, dataclass + +from flask import Flask, jsonify, request + + +@dataclass +class Book: + id: int + title: str + author: str + + +class BookRepository: + """State and operations are encapsulated outside HTTP handlers.""" + + def __init__(self): + self._books = {} + self._next_id = 1 + + def list(self): + return list(self._books.values()) + + def get(self, book_id): + return self._books.get(book_id) + + def create(self, title, author): + book = Book(id=self._next_id, title=title, author=author) + self._books[book.id] = book + self._next_id += 1 + return book + + def update(self, book_id, title, author): + book = self.get(book_id) + if book is None: + return None + book.title = title + book.author = author + return book + + def delete(self, book_id): + return self._books.pop(book_id, None) is not None + + +def validate_json_body(): + """Returns validated data or None; contrast with FastAPI/Pydantic.""" + if not request.is_json: + return None + data = request.get_json(silent=True) + if not isinstance(data, dict): + return None + title, author = data.get("title"), data.get("author") + if not isinstance(title, str) or not isinstance(author, str): + return None + title, author = title.strip(), author.strip() + if not title or not author or len(title) > 120 or len(author) > 120: + return None + return title, author + + +def create_app(repository=None): + app = Flask(__name__) + books = repository if repository is not None else BookRepository() + + @app.get("/books") + def list_books(): + return jsonify([asdict(book) for book in books.list()]) + + @app.get("/books/") + def get_book(book_id): + book = books.get(book_id) + if book is None: + return jsonify(error="Book not found"), 404 + return jsonify(asdict(book)) + + @app.post("/books") + def create_book(): + fields = validate_json_body() + if fields is None: + return jsonify(error="title and author must be non-empty strings (max 120 chars)"), 400 + return jsonify(asdict(books.create(*fields))), 201 + + @app.put("/books/") + def update_book(book_id): + fields = validate_json_body() + if fields is None: + return jsonify(error="title and author must be non-empty strings (max 120 chars)"), 400 + book = books.update(book_id, *fields) + if book is None: + return jsonify(error="Book not found"), 404 + return jsonify(asdict(book)) + + @app.delete("/books/") + def delete_book(book_id): + if not books.delete(book_id): + return jsonify(error="Book not found"), 404 + return "", 204 + + return app + + +app = create_app() diff --git a/examples/web-frameworks/flask/requirements.txt b/examples/web-frameworks/flask/requirements.txt new file mode 100644 index 0000000..05cf542 --- /dev/null +++ b/examples/web-frameworks/flask/requirements.txt @@ -0,0 +1 @@ +Flask>=3.1,<4 diff --git a/examples/web-frameworks/flask/tests/test_api.py b/examples/web-frameworks/flask/tests/test_api.py new file mode 100644 index 0000000..742f2ba --- /dev/null +++ b/examples/web-frameworks/flask/tests/test_api.py @@ -0,0 +1,54 @@ +import unittest + +from app import BookRepository, create_app + + +class BookApiTests(unittest.TestCase): + def setUp(self): + self.client = create_app(BookRepository()).test_client() + + def test_complete_crud(self): + self.assertEqual(self.client.get("/books").json, []) + created = self.client.post( + "/books", json={"title": " Clean Code ", "author": " Robert C. Martin "} + ) + self.assertEqual(created.status_code, 201) + self.assertEqual( + created.json, {"id": 1, "title": "Clean Code", "author": "Robert C. Martin"} + ) + self.assertEqual(self.client.get("/books/1").json, created.json) + self.assertEqual(len(self.client.get("/books").json), 1) + + updated = self.client.put( + "/books/1", json={"title": "Refactoring", "author": "Martin Fowler"} + ) + self.assertEqual(updated.status_code, 200) + self.assertEqual(updated.json["title"], "Refactoring") + self.assertEqual(self.client.delete("/books/1").status_code, 204) + self.assertEqual(self.client.get("/books").json, []) + + def test_input_validation(self): + for payload in [ + {"title": " ", "author": "Jane"}, + {"title": "Book", "author": ""}, + {"title": 123, "author": "Jane"}, + {"title": "x" * 121, "author": "Jane"}, + {"title": "Only title"}, + ]: + with self.subTest(payload=payload): + self.assertEqual(self.client.post("/books", json=payload).status_code, 400) + self.assertEqual( + self.client.post("/books", data="not json", content_type="application/json").status_code, + 400, + ) + + def test_missing_book(self): + self.assertEqual(self.client.get("/books/9").status_code, 404) + self.assertEqual( + self.client.put("/books/9", json={"title": "X", "author": "Y"}).status_code, 404 + ) + self.assertEqual(self.client.delete("/books/9").status_code, 404) + + +if __name__ == "__main__": + unittest.main()