Skip to content

Commit 12eb21b

Browse files
committed
feat(api): add env config and caching headers
1 parent 047a2f1 commit 12eb21b

File tree

12 files changed

+227
-17
lines changed

12 files changed

+227
-17
lines changed

.env.example

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Arahkan ke file data jika butuh override (opsional)
2+
WILAYAH_DATA_PATH=app/data/wilayah.json
3+
WILAYAH_METADATA_PATH=app/data/metadata.json
4+
5+
# Konfigurasi aplikasi
6+
APP_VERSION=0.1.0-api
7+
PORT=8000
8+
9+
# CORS origins (pisahkan dengan koma jika lebih dari satu)
10+
# Contoh: https://contoh.com,https://sub.contoh.com
11+
ALLOW_ORIGINS=

DEPLOYMENT.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# DEPLOYMENT
2+
3+
Panduan singkat menyiapkan **Sistem Wilayah Indonesia API** untuk lingkungan publik.
4+
5+
## Prasyarat
6+
- Python 3.12+
7+
- Virtual environment
8+
- File data bawaan: `app/data/wilayah.json` dan `app/data/metadata.json`
9+
- (Opsional) Salin `.env.example` menjadi `.env` lalu sesuaikan nilai variabel.
10+
11+
## Menjalankan Lokal / Development
12+
```bash
13+
python -m venv .venv
14+
source .venv/bin/activate
15+
pip install -r requirements.txt -r requirements-dev.txt
16+
python -m uvicorn app.main:app --host 127.0.0.1 --port ${PORT:-8000} --reload
17+
```
18+
19+
## Menjalankan Production (single process)
20+
```bash
21+
export APP_VERSION=${APP_VERSION:-0.1.0-api}
22+
export PORT=${PORT:-8000}
23+
# Atur origins hanya jika perlu CORS publik, mis. ALLOW_ORIGINS=https://example.com,https://sub.example.com
24+
export ALLOW_ORIGINS=""
25+
python -m uvicorn app.main:app --host 0.0.0.0 --port "$PORT"
26+
```
27+
28+
Gunakan reverse proxy (nginx/Caddy) untuk TLS dan buffering jika diperlukan. File data bersifat statis; respons sudah menyertakan `Cache-Control` dan `ETag` untuk efisiensi.
29+
30+
## Deploy ke Platform Umum (garis besar)
31+
- **Render/Fly.io/Railway**: gunakan Dockerfile yang sudah ada. Set env `APP_VERSION`, `PORT`, `ALLOW_ORIGINS`, dan mount data jika ingin override `WILAYAH_DATA_PATH`/`WILAYAH_METADATA_PATH`.
32+
- **VM/Bare metal**: buat service manager (systemd/supervisor) yang menjalankan perintah uvicorn production di atas.
33+
- **Container orchestrator**: gunakan image hasil `docker build -t sistem-wilayah-indonesia-api .` dan ekspos port `8000`.
34+
35+
Pastikan mengecek `/health` dan `/docs` setelah deploy, serta jalankan `make check` pada pipeline sebelum merilis.

README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ FastAPI backend + CLI untuk data provinsi/kabupaten/kota. Snapshot dataset 2024-
77
- Versi API: 0.1.0-api (branch feat/backend-api)
88
- Swagger/OpenAPI: http://127.0.0.1:8000/docs
99

10+
## Base URL (Production)
11+
- Placeholder: https://api.example.com
12+
- Swagger: https://api.example.com/docs
13+
1014
## Quickstart Lokal
1115
```bash
1216
python -m venv .venv
@@ -28,14 +32,21 @@ Gunakan host 0.0.0.0 hanya untuk container/jaringan jika Anda paham risikonya.
2832
- GET /v1/provinces/{name} (alias & case-insensitive, 409 jika ambigu)
2933
- GET /v1/search?q=...&type=all|kabupaten|kota
3034

31-
## Contoh curl (127.0.0.1)
35+
## Contoh curl (lokal & produksi)
3236
```bash
37+
# Lokal
3338
curl http://127.0.0.1:8000/health
3439
curl http://127.0.0.1:8000/v1/stats
3540
curl http://127.0.0.1:8000/v1/meta
3641
curl "http://127.0.0.1:8000/v1/provinces?limit=5&offset=0"
3742
curl http://127.0.0.1:8000/v1/provinces/jabar
3843
curl "http://127.0.0.1:8000/v1/search?q=bima&type=all"
44+
45+
# Produksi (ganti base URL sesuai deploy)
46+
BASE_URL=https://api.example.com
47+
curl "$BASE_URL/health"
48+
curl "$BASE_URL/v1/meta"
49+
curl "$BASE_URL/v1/search?q=bima&type=all"
3950
```
4051

4152
## Pengembangan
@@ -49,6 +60,7 @@ mypy app
4960
- app/data/wilayah.json
5061
- app/data/metadata.json
5162
- Catatan: dataset snapshot kompilasi (2024-12) dan belum diverifikasi penuh terhadap dokumen pemutakhiran resmi tahun 2025; untuk kepentingan hukum, rujuk langsung ke instansi pemerintah (lihat DATA_SOURCES.md).
63+
- Lisensi data mengikuti sumber aslinya dan membutuhkan atribusi yang sesuai; lisensi kode: MIT (lihat LICENSE). DETAIL sumber dan atribusi: DATA_SOURCES.md.
5264

5365
## Docker / Podman (opsional; CI memverifikasi docker build)
5466
```bash
@@ -76,3 +88,4 @@ sistem_wilayah_indonesia.py
7688
- CHANGELOG: CHANGELOG.md
7789
- Sumber & metodologi: DATA_SOURCES.md
7890
- Lisensi: LICENSE
91+
- Deployment: DEPLOYMENT.md

app/api/deps.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
from __future__ import annotations
22

33
from app.core.config import settings
4+
from app.core.cache import compute_etag
45
from app.services.metadata_service import MetadataService
56
from app.services.wilayah_service import WilayahService
67

78
wilayah_service = WilayahService(settings.data_path)
89
metadata_service = MetadataService(settings.metadata_path)
10+
11+
12+
def get_data_etag() -> str:
13+
return compute_etag([settings.data_path, settings.metadata_path])

app/api/v1/meta.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,23 @@
11
from __future__ import annotations
22

3-
from fastapi import APIRouter
3+
from fastapi import APIRouter, Request, Response
44

55
from app.api import deps
6+
from app.core.cache import CACHE_CONTROL_HEADER_VALUE
67
from app.core.errors import build_error
78
from app.schemas.wilayah import ErrorResponse, Metadata
89

910
router = APIRouter()
1011

1112

1213
@router.get("/v1/meta", response_model=Metadata, responses={404: {"model": ErrorResponse}})
13-
async def get_metadata() -> Metadata:
14+
async def get_metadata(request: Request, response: Response) -> Metadata | Response:
15+
etag = deps.get_data_etag()
16+
if request.headers.get("if-none-match") == etag:
17+
return Response(
18+
status_code=304,
19+
headers={"ETag": etag, "Cache-Control": CACHE_CONTROL_HEADER_VALUE},
20+
)
1421
try:
1522
metadata = deps.metadata_service.get_metadata_with_computed(deps.wilayah_service)
1623
except FileNotFoundError as exc:
@@ -25,4 +32,6 @@ async def get_metadata() -> Metadata:
2532
code="metadata_invalid",
2633
message=str(exc),
2734
) from exc
35+
response.headers["Cache-Control"] = CACHE_CONTROL_HEADER_VALUE
36+
response.headers["ETag"] = etag
2837
return Metadata(**metadata)

app/api/v1/provinces.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
from __future__ import annotations
22

3-
from fastapi import APIRouter, Query
3+
from fastapi import APIRouter, Query, Request, Response
44

55
from app.api import deps
6+
from app.core.cache import CACHE_CONTROL_HEADER_VALUE
67
from app.core.errors import build_error
78
from app.schemas.wilayah import ErrorResponse, Province, ProvinceListResponse
89

@@ -15,11 +16,21 @@
1516
responses={422: {"model": ErrorResponse}},
1617
)
1718
async def list_provinces(
19+
request: Request,
20+
response: Response,
1821
limit: int = Query(20, ge=1, le=200),
1922
offset: int = Query(0, ge=0),
20-
) -> ProvinceListResponse:
23+
) -> ProvinceListResponse | Response:
24+
etag = deps.get_data_etag()
25+
if request.headers.get("if-none-match") == etag:
26+
return Response(
27+
status_code=304,
28+
headers={"ETag": etag, "Cache-Control": CACHE_CONTROL_HEADER_VALUE},
29+
)
2130
items, total = deps.wilayah_service.list_provinces(limit=limit, offset=offset)
2231
province_models = [Province(**item) for item in items]
32+
response.headers["Cache-Control"] = CACHE_CONTROL_HEADER_VALUE
33+
response.headers["ETag"] = etag
2334
return ProvinceListResponse(
2435
count=total, limit=limit, offset=offset, items=province_models
2536
)
@@ -30,7 +41,10 @@ async def list_provinces(
3041
response_model=Province,
3142
responses={404: {"model": ErrorResponse}, 409: {"model": ErrorResponse}},
3243
)
33-
async def get_province(name: str) -> Province:
44+
async def get_province(
45+
name: str, request: Request, response: Response
46+
) -> Province | Response:
47+
etag = deps.get_data_etag()
3448
province, candidates = deps.wilayah_service.get_province(name)
3549
if candidates:
3650
raise build_error(
@@ -45,4 +59,11 @@ async def get_province(name: str) -> Province:
4559
code="province_not_found",
4660
message=f"Provinsi '{name}' tidak ditemukan",
4761
)
62+
if request.headers.get("if-none-match") == etag:
63+
return Response(
64+
status_code=304,
65+
headers={"ETag": etag, "Cache-Control": CACHE_CONTROL_HEADER_VALUE},
66+
)
67+
response.headers["Cache-Control"] = CACHE_CONTROL_HEADER_VALUE
68+
response.headers["ETag"] = etag
4869
return Province(**province)

app/api/v1/search.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
from __future__ import annotations
22

3-
from fastapi import APIRouter, Query
3+
from fastapi import APIRouter, Query, Request, Response
44

55
from app.api import deps
6+
from app.core.cache import CACHE_CONTROL_HEADER_VALUE
67
from app.schemas.wilayah import ErrorResponse, SearchResponse, SearchResult, SearchType
78

89
router = APIRouter()
@@ -14,11 +15,21 @@
1415
responses={422: {"model": ErrorResponse}},
1516
)
1617
async def search(
18+
request: Request,
19+
response: Response,
1720
q: str = Query(..., min_length=1),
1821
search_type: SearchType = Query(SearchType.all, alias="type"),
19-
) -> SearchResponse:
22+
) -> SearchResponse | Response:
23+
etag = deps.get_data_etag()
24+
if request.headers.get("if-none-match") == etag:
25+
return Response(
26+
status_code=304,
27+
headers={"ETag": etag, "Cache-Control": CACHE_CONTROL_HEADER_VALUE},
28+
)
2029
results = deps.wilayah_service.search(q, search_type.value)
2130
result_models = [SearchResult(**result) for result in results]
31+
response.headers["Cache-Control"] = CACHE_CONTROL_HEADER_VALUE
32+
response.headers["ETag"] = etag
2233
return SearchResponse(
2334
query=q, type=search_type, count=len(result_models), results=result_models
2435
)

app/api/v1/stats.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,22 @@
11
from __future__ import annotations
22

3-
from fastapi import APIRouter
3+
from fastapi import APIRouter, Request, Response
44

55
from app.api import deps
6+
from app.core.cache import CACHE_CONTROL_HEADER_VALUE
67
from app.schemas.wilayah import StatsResponse
78

89
router = APIRouter()
910

1011

1112
@router.get("/v1/stats", response_model=StatsResponse)
12-
async def get_stats() -> StatsResponse:
13+
async def get_stats(request: Request, response: Response) -> StatsResponse | Response:
14+
etag = deps.get_data_etag()
15+
if request.headers.get("if-none-match") == etag:
16+
return Response(
17+
status_code=304,
18+
headers={"ETag": etag, "Cache-Control": CACHE_CONTROL_HEADER_VALUE},
19+
)
20+
response.headers["Cache-Control"] = CACHE_CONTROL_HEADER_VALUE
21+
response.headers["ETag"] = etag
1322
return StatsResponse(**deps.wilayah_service.get_stats())

app/core/cache.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from __future__ import annotations
2+
3+
import hashlib
4+
from pathlib import Path
5+
from typing import Iterable
6+
7+
CACHE_CONTROL_HEADER_VALUE = "public, max-age=86400"
8+
9+
10+
def compute_etag(paths: Iterable[Path]) -> str:
11+
hasher = hashlib.sha256()
12+
for path in sorted(paths, key=lambda p: str(p)):
13+
try:
14+
stat = path.stat()
15+
hasher.update(f"{path}:{stat.st_mtime_ns}:{stat.st_size}".encode())
16+
except FileNotFoundError:
17+
hasher.update(f"{path}:missing".encode())
18+
return f'W/"{hasher.hexdigest()}"'

app/core/config.py

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import os
44
from dataclasses import dataclass
55
from pathlib import Path
6+
from typing import List
67

78
BASE_DIR = Path(__file__).resolve().parent.parent
89

@@ -21,15 +22,55 @@ def _resolve_metadata_path() -> Path:
2122
return BASE_DIR / "data" / "metadata.json"
2223

2324

25+
def _load_dataset_version(metadata_path: Path) -> str:
26+
try:
27+
if metadata_path.exists():
28+
import json
29+
30+
with metadata_path.open("r", encoding="utf-8") as file:
31+
data = json.load(file)
32+
if isinstance(data, dict) and "dataset_version" in data:
33+
return str(data["dataset_version"])
34+
except Exception:
35+
# Fallback handled below
36+
pass
37+
return "unknown"
38+
39+
2440
@dataclass
2541
class Settings:
26-
app_name: str = "Sistem Wilayah Indonesia API"
27-
version: str = "0.1.0-api"
28-
description: str = (
29-
"FastAPI service exposing Indonesian provinces, kabupaten, and kota data"
42+
app_name: str
43+
version: str
44+
description: str
45+
data_path: Path
46+
metadata_path: Path
47+
dataset_version: str
48+
allow_origins: List[str]
49+
port: int
50+
51+
52+
def _load_settings() -> Settings:
53+
data_path = _resolve_data_path()
54+
metadata_path = _resolve_metadata_path()
55+
dataset_version = _load_dataset_version(metadata_path)
56+
version = os.getenv("APP_VERSION", "0.1.0-api")
57+
description = (
58+
f"Sistem Wilayah Indonesia API — snapshot dataset {dataset_version}. "
59+
"Data provinsi, kabupaten, kota dalam format JSON."
60+
)
61+
allow_origins_env = os.getenv("ALLOW_ORIGINS", "")
62+
allow_origins = [origin.strip() for origin in allow_origins_env.split(",") if origin.strip()]
63+
port = int(os.getenv("PORT", "8000"))
64+
return Settings(
65+
app_name="Sistem Wilayah Indonesia API",
66+
version=version,
67+
description=description,
68+
data_path=data_path,
69+
metadata_path=metadata_path,
70+
dataset_version=dataset_version,
71+
allow_origins=allow_origins,
72+
port=port,
3073
)
31-
data_path: Path = _resolve_data_path()
32-
metadata_path: Path = _resolve_metadata_path()
3374

3475

35-
settings = Settings()
76+
settings = _load_settings()

0 commit comments

Comments
 (0)