github-actions[bot] commited on
Commit
90a0b2c
·
0 Parent(s):

Deploy GitHub snapshot to Hugging Face Space

Browse files
.dockerignore ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ .github
3
+ .venv
4
+ .pytest_cache
5
+ .ruff_cache
6
+ .mypy_cache
7
+ __pycache__
8
+ *.py[cod]
9
+ *.log
10
+ .env
11
+ img
12
+ tests
13
+ scripts
.env.example ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ APP_NAME=gungfi-webhook-be
2
+ LOG_LEVEL=INFO
3
+
4
+ # Keep this path aligned with the n8n template.
5
+ WEBHOOK_PATH=/cloudinary-trigger
6
+
7
+ # AI pipeline base URL. The app posts webhook data to /process.
8
+ AI_PIPELINE_URL=https://be-aipipeline.bluecoast-4238dc8b.eastasia.azurecontainerapps.io
9
+ AI_PIPELINE_TIMEOUT_SECONDS=60
10
+
11
+ # Supabase REST API. Use a service role key on the backend only.
12
+ SUPABASE_URL=https://your-project.supabase.co
13
+ SUPABASE_SERVICE_ROLE_KEY=replace-me
14
+ SUPABASE_TABLE=reports
15
+
16
+ # Cloudinary webhook signature validation.
17
+ # Cloudinary sends X-Cld-Signature and X-Cld-Timestamp.
18
+ CLOUDINARY_API_SECRET=replace-me
19
+ CLOUDINARY_CLOUD_NAME=replace-me
20
+ CLOUDINARY_API_KEY=replace-me
21
+ CLOUDINARY_SIGNATURE_REQUIRED=true
22
+ CLOUDINARY_SIGNATURE_TOLERANCE_SECONDS=7200
.github/workflows/deploy-huggingface-space.yml ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Deploy to Hugging Face Spaces
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ workflow_dispatch:
8
+
9
+ concurrency:
10
+ group: huggingface-space
11
+ cancel-in-progress: true
12
+
13
+ jobs:
14
+ test-and-deploy:
15
+ runs-on: ubuntu-latest
16
+
17
+ steps:
18
+ - name: Checkout repository
19
+ uses: actions/checkout@v4
20
+ with:
21
+ fetch-depth: 0
22
+
23
+ - name: Install uv
24
+ uses: astral-sh/setup-uv@v5
25
+
26
+ - name: Set up Python
27
+ uses: actions/setup-python@v5
28
+ with:
29
+ python-version: "3.12"
30
+
31
+ - name: Install dependencies
32
+ run: uv sync --frozen
33
+
34
+ - name: Run tests
35
+ env:
36
+ APP_NAME: gungfi-webhook-be
37
+ LOG_LEVEL: INFO
38
+ WEBHOOK_PATH: /cloudinary-trigger
39
+ AI_PIPELINE_URL: http://be-aipipeline-jeki.indonesiacentral.azurecontainer.io:8000
40
+ AI_PIPELINE_TIMEOUT_SECONDS: 60
41
+ SUPABASE_URL: https://example.supabase.co
42
+ SUPABASE_SERVICE_ROLE_KEY: test-service-role-key
43
+ SUPABASE_TABLE: reports
44
+ CLOUDINARY_API_SECRET: test-cloudinary-secret
45
+ CLOUDINARY_SIGNATURE_REQUIRED: true
46
+ CLOUDINARY_SIGNATURE_TOLERANCE_SECONDS: 7200
47
+ run: uv run pytest
48
+
49
+ - name: Deploy to Hugging Face Space
50
+ env:
51
+ HF_USERNAME: ${{ secrets.HF_USERNAME }}
52
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
53
+ HF_SPACE_ID: ${{ secrets.HF_SPACE_ID }}
54
+ run: |
55
+ if [ -z "$HF_USERNAME" ] || [ -z "$HF_TOKEN" ] || [ -z "$HF_SPACE_ID" ]; then
56
+ echo "Missing one or more required secrets: HF_USERNAME, HF_TOKEN, HF_SPACE_ID"
57
+ exit 1
58
+ fi
59
+
60
+ SPACE_ID="$HF_SPACE_ID"
61
+ SPACE_ID="${SPACE_ID#https://huggingface.co/spaces/}"
62
+ SPACE_ID="${SPACE_ID#http://huggingface.co/spaces/}"
63
+ SPACE_ID="${SPACE_ID%/}"
64
+
65
+ if ! echo "$SPACE_ID" | grep -Eq '^[^/]+/[^/]+$'; then
66
+ echo "HF_SPACE_ID must be owner/space-name or a Hugging Face Space URL."
67
+ exit 1
68
+ fi
69
+
70
+ rm -rf hf-space
71
+ mkdir hf-space
72
+ git archive --format=tar HEAD | tar -x -C hf-space
73
+
74
+ oversized_files="$(find hf-space -type f -size +10M -print)"
75
+ if [ -n "$oversized_files" ]; then
76
+ echo "Hugging Face rejects regular Git files larger than 10 MiB."
77
+ echo "$oversized_files"
78
+ exit 1
79
+ fi
80
+
81
+ cd hf-space
82
+ git init -b main
83
+ git config user.name "github-actions[bot]"
84
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
85
+ git add -A
86
+ git commit -m "Deploy GitHub snapshot to Hugging Face Space"
87
+ git remote add huggingface "https://${HF_USERNAME}:${HF_TOKEN}@huggingface.co/spaces/${SPACE_ID}"
88
+
89
+ for attempt in 1 2 3 4 5; do
90
+ if git push --force huggingface HEAD:main; then
91
+ exit 0
92
+ fi
93
+
94
+ sleep_seconds=$((attempt * 30))
95
+ echo "Hugging Face push failed on attempt ${attempt}. Retrying in ${sleep_seconds}s..."
96
+ sleep "$sleep_seconds"
97
+ done
98
+
99
+ echo "Hugging Face deploy failed after 5 attempts."
100
+ exit 1
.gitignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ .mypy_cache/
7
+ .env
8
+ *.log
9
+ img/
Dockerfile ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1 \
4
+ PYTHONUNBUFFERED=1 \
5
+ UV_SYSTEM_PYTHON=1
6
+
7
+ WORKDIR /app
8
+
9
+ RUN pip install --no-cache-dir uv
10
+
11
+ COPY pyproject.toml uv.lock ./
12
+ RUN uv sync --frozen --no-dev
13
+
14
+ COPY app ./app
15
+
16
+ EXPOSE 7860
17
+
18
+ CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
IniBukanBuatHackathon.json ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "My workflow",
3
+ "nodes": [
4
+ {
5
+ "parameters": {
6
+ "httpMethod": "POST",
7
+ "path": "cloudinary-trigger",
8
+ "options": {}
9
+ },
10
+ "name": "Cloudinary Webhook",
11
+ "type": "n8n-nodes-base.webhook",
12
+ "typeVersion": 1,
13
+ "position": [
14
+ 3344,
15
+ 992
16
+ ],
17
+ "id": "ca173e05-e036-48be-99ae-42ce18e457ed",
18
+ "webhookId": "e6dc3997-4c85-42fc-adde-396c60a64029"
19
+ },
20
+ {
21
+ "parameters": {
22
+ "mode": "runOnceForEachItem",
23
+ "jsCode": "const body = $input.item.json.body || {};\nconst context = body.context || {};\nconst custom = context.custom || {};\n\nreturn {\n json: {\n id: custom.Id || null,\n latitude: custom.latitude || null,\n longitude: custom.longitude || null,\n description: custom.description || \"\",\n before_img_url: body.secure_url || \"\"\n }\n};"
24
+ },
25
+ "name": "Parse Data",
26
+ "type": "n8n-nodes-base.code",
27
+ "typeVersion": 2,
28
+ "position": [
29
+ 3568,
30
+ 992
31
+ ],
32
+ "id": "d9621bf8-aa4f-434c-992f-f2d0088a250d"
33
+ },
34
+ {
35
+ "parameters": {
36
+ "conditions": {
37
+ "boolean": [
38
+ {
39
+ "value1": "={{!!$node[\"Parse Data\"].json.id}}",
40
+ "value2": true
41
+ }
42
+ ]
43
+ }
44
+ },
45
+ "name": "Loop Protection",
46
+ "type": "n8n-nodes-base.if",
47
+ "typeVersion": 1,
48
+ "position": [
49
+ 3792,
50
+ 992
51
+ ],
52
+ "id": "fc38d8a7-421b-4239-8382-e7afa0feead2"
53
+ },
54
+ {
55
+ "parameters": {
56
+ "method": "POST",
57
+ "url": "http://be-aipipeline-jeki.indonesiacentral.azurecontainer.io:8000/process-url",
58
+ "sendBody": true,
59
+ "specifyBody": "json",
60
+ "jsonBody": "={\n \"id\": \"{{$node[\"Parse Data\"].json.id}}\",\n \"longitude\": {{$node[\"Parse Data\"].json.longitude}},\n \"latitude\": {{$node[\"Parse Data\"].json.latitude}},\n \"before_img_url\": \"{{$node[\"Parse Data\"].json.before_img_url}}\"\n}",
61
+ "options": {
62
+ "timeout": 60000
63
+ }
64
+ },
65
+ "name": "Trigger BE-AIPipeline",
66
+ "type": "n8n-nodes-base.httpRequest",
67
+ "typeVersion": 3,
68
+ "position": [
69
+ 4016,
70
+ 992
71
+ ],
72
+ "id": "8cef1520-4c71-44f4-96f9-8f1180a0ffa0",
73
+ "notes": "BE akan download image, process AI, UPDATE Supabase, lalu hapus temp file"
74
+ },
75
+ {
76
+ "parameters": {
77
+ "assignments": {
78
+ "assignments": [
79
+ {
80
+ "id": "id-1",
81
+ "name": "id",
82
+ "value": "={{ $('Parse Data').item.json.id }}",
83
+ "type": "string"
84
+ },
85
+ {
86
+ "id": "id-2",
87
+ "name": "destruct_class",
88
+ "value": "={{ $('Trigger BE-AIPipeline').item.json.classification.condition }}",
89
+ "type": "string"
90
+ },
91
+ {
92
+ "id": "id-3",
93
+ "name": "location_score",
94
+ "value": "={{ $('Trigger BE-AIPipeline').item.json.priority_score }}",
95
+ "type": "number"
96
+ },
97
+ {
98
+ "id": "id-4",
99
+ "name": "total_score",
100
+ "value": "={{ $('Trigger BE-AIPipeline').item.json.max_impact }}",
101
+ "type": "number"
102
+ },
103
+ {
104
+ "id": "id-5",
105
+ "name": "status",
106
+ "value": "complete",
107
+ "type": "string"
108
+ }
109
+ ]
110
+ },
111
+ "includeOtherFields": true,
112
+ "options": {}
113
+ },
114
+ "id": "5a218d30-f6f2-45ca-942f-c73d80e4b3f3",
115
+ "name": "Prepare Supabase Update Data",
116
+ "type": "n8n-nodes-base.set",
117
+ "typeVersion": 3.4,
118
+ "position": [
119
+ 4240,
120
+ 992
121
+ ]
122
+ },
123
+ {
124
+ "parameters": {
125
+ "operation": "update",
126
+ "tableId": "reports",
127
+ "filterType": "string",
128
+ "filterString": "={{ 'id=eq.' + $('Prepare Supabase Update Data').item.json.id }}",
129
+ "fieldsUi": {
130
+ "fieldValues": [
131
+ {
132
+ "fieldId": "destruct_class",
133
+ "fieldValue": "={{ $('Prepare Supabase Update Data').item.json.destruct_class }}"
134
+ },
135
+ {
136
+ "fieldId": "location_score",
137
+ "fieldValue": "={{ $('Prepare Supabase Update Data').item.json.location_score }}"
138
+ },
139
+ {
140
+ "fieldId": "total_score",
141
+ "fieldValue": "={{ $('Prepare Supabase Update Data').item.json.total_score }}"
142
+ },
143
+ {
144
+ "fieldId": "status",
145
+ "fieldValue": "={{ $('Prepare Supabase Update Data').item.json.status }}"
146
+ }
147
+ ]
148
+ }
149
+ },
150
+ "id": "49aeac17-9849-47ba-ba57-93173b6fdbf2",
151
+ "name": "Update Supabase Record",
152
+ "type": "n8n-nodes-base.supabase",
153
+ "typeVersion": 1,
154
+ "position": [
155
+ 4464,
156
+ 992
157
+ ],
158
+ "credentials": {
159
+ "supabaseApi": {
160
+ "id": "Ce7gSa8qnBBCcA3y",
161
+ "name": "Supabase account"
162
+ }
163
+ }
164
+ }
165
+ ],
166
+ "pinData": {},
167
+ "connections": {
168
+ "Cloudinary Webhook": {
169
+ "main": [
170
+ [
171
+ {
172
+ "node": "Parse Data",
173
+ "type": "main",
174
+ "index": 0
175
+ }
176
+ ]
177
+ ]
178
+ },
179
+ "Parse Data": {
180
+ "main": [
181
+ [
182
+ {
183
+ "node": "Loop Protection",
184
+ "type": "main",
185
+ "index": 0
186
+ }
187
+ ]
188
+ ]
189
+ },
190
+ "Loop Protection": {
191
+ "main": [
192
+ [
193
+ {
194
+ "node": "Trigger BE-AIPipeline",
195
+ "type": "main",
196
+ "index": 0
197
+ }
198
+ ]
199
+ ]
200
+ },
201
+ "Trigger BE-AIPipeline": {
202
+ "main": [
203
+ [
204
+ {
205
+ "node": "Prepare Supabase Update Data",
206
+ "type": "main",
207
+ "index": 0
208
+ }
209
+ ]
210
+ ]
211
+ },
212
+ "Prepare Supabase Update Data": {
213
+ "main": [
214
+ [
215
+ {
216
+ "node": "Update Supabase Record",
217
+ "type": "main",
218
+ "index": 0
219
+ }
220
+ ]
221
+ ]
222
+ }
223
+ },
224
+ "active": true,
225
+ "settings": {
226
+ "executionOrder": "v1",
227
+ "availableInMCP": false
228
+ },
229
+ "versionId": "f32ea910-f847-438f-aee1-657680d0cf9e",
230
+ "meta": {
231
+ "templateCredsSetupCompleted": true,
232
+ "instanceId": "ec50aa1d8c0e15b703de4ec09bc4ae99ba1d29b466ee546490a9882118ec4069"
233
+ },
234
+ "id": "BFLADivi1dvw4GiMd9fuK",
235
+ "tags": []
236
+ }
README.md ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Gungfi Webhook BE
3
+ colorFrom: blue
4
+ colorTo: green
5
+ sdk: docker
6
+ pinned: false
7
+ ---
8
+
9
+ # Gungfi Webhook BE
10
+
11
+ FastAPI backend pengganti workflow n8n `IniBukanBuatHackathon.json`.
12
+
13
+ Flow yang dipertahankan:
14
+
15
+ 1. Terima Cloudinary webhook di `POST /cloudinary-trigger`.
16
+ 2. Parse `body.context.custom` menjadi `id`, `latitude`, `longitude`, `description`, dan `before_img_url`.
17
+ 3. Jalankan loop protection: kalau `Id` kosong, tidak meneruskan proses.
18
+ 4. Download image dari Cloudinary, lalu kirim multipart form-data ke BE AI Pipeline `POST /process`.
19
+ 5. Update Supabase tabel `reports` dengan `destruct_class`, `location_score`, `total_score`, dan `status=complete`.
20
+
21
+ ## Setup
22
+
23
+ ```powershell
24
+ uv venv
25
+ uv sync
26
+ Copy-Item .env.example .env
27
+ ```
28
+
29
+ Isi `.env`, terutama `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, dan `CLOUDINARY_API_SECRET`.
30
+
31
+ ## Run
32
+
33
+ ```powershell
34
+ uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
35
+ ```
36
+
37
+ Cloudinary notification URL:
38
+
39
+ ```text
40
+ https://your-domain/cloudinary-trigger
41
+ ```
42
+
43
+ Set URL tersebut sebagai global webhook Notification URL di Cloudinary Console, atau di upload preset yang dipakai aplikasi. Dengan begitu aplikasi upload tidak perlu mengirim parameter `notification_url` pada setiap request.
44
+
45
+ ## Test
46
+
47
+ ```powershell
48
+ uv run pytest
49
+ ```
50
+
51
+ ## CI/CD ke Hugging Face Spaces
52
+
53
+ Repository ini siap dideploy sebagai Docker Space. Buat Space di Hugging Face dengan SDK `Docker`, lalu tambahkan secret berikut di GitHub repository:
54
+
55
+ ```text
56
+ HF_USERNAME=your-huggingface-username
57
+ HF_TOKEN=hf_...
58
+ HF_SPACE_ID=your-huggingface-username/your-space-name
59
+ ```
60
+
61
+ Tambahkan juga environment variable aplikasi di Hugging Face Space settings:
62
+
63
+ ```text
64
+ APP_NAME=gungfi-webhook-be
65
+ LOG_LEVEL=INFO
66
+ WEBHOOK_PATH=/cloudinary-trigger
67
+ AI_PIPELINE_URL=https://be-aipipeline.bluecoast-4238dc8b.eastasia.azurecontainerapps.io
68
+ AI_PIPELINE_TIMEOUT_SECONDS=60
69
+ SUPABASE_URL=https://your-project.supabase.co
70
+ SUPABASE_SERVICE_ROLE_KEY=replace-me
71
+ SUPABASE_TABLE=reports
72
+ CLOUDINARY_API_SECRET=replace-me
73
+ CLOUDINARY_SIGNATURE_REQUIRED=true
74
+ CLOUDINARY_SIGNATURE_TOLERANCE_SECONDS=7200
75
+ ```
76
+
77
+ Setiap push ke branch `main` akan menjalankan test, lalu melakukan force-push isi repo ke Hugging Face Space.
78
+
79
+ ## Test Upload Cloudinary
80
+
81
+ Tambahkan ini ke `.env`:
82
+
83
+ ```text
84
+ CLOUDINARY_CLOUD_NAME=...
85
+ CLOUDINARY_API_KEY=...
86
+ CLOUDINARY_API_SECRET=...
87
+ ```
88
+
89
+ Untuk test upload file `img/DSC06041.JPG` ke Cloudinary:
90
+
91
+ ```powershell
92
+ uv run python scripts/upload_cloudinary_test.py
93
+ ```
94
+
95
+ Kalau ingin Cloudinary memanggil FastAPI lokal, expose dulu server lokal ke HTTPS publik:
96
+
97
+ ```powershell
98
+ uv run uvicorn app.main:app --host 127.0.0.1 --port 8000
99
+ ngrok http 8000
100
+ ```
101
+
102
+ Setelah dapat URL HTTPS dari tunnel, set global webhook Notification URL atau upload preset Notification URL di Cloudinary ke `https://your-tunnel-url/cloudinary-trigger`, lalu jalankan lagi script upload Cloudinary.
app/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Gungfi webhook backend."""
app/cloudinary.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import hashlib
2
+ import hmac
3
+ import time
4
+
5
+ from fastapi import HTTPException, Request, status
6
+
7
+ from app.config import Settings
8
+
9
+
10
+ SIGNATURE_HEADER = "x-cld-signature"
11
+ TIMESTAMP_HEADER = "x-cld-timestamp"
12
+
13
+
14
+ def verify_notification_signature(raw_body: bytes, signature: str, timestamp: str, api_secret: str) -> bool:
15
+ signed = raw_body + timestamp.encode("utf-8") + api_secret.encode("utf-8")
16
+ sha1_digest = hashlib.sha1(signed).hexdigest()
17
+ sha256_digest = hashlib.sha256(signed).hexdigest()
18
+ return hmac.compare_digest(signature, sha1_digest) or hmac.compare_digest(signature, sha256_digest)
19
+
20
+
21
+ async def enforce_cloudinary_signature(request: Request, settings: Settings) -> bytes:
22
+ raw_body = await request.body()
23
+ if not settings.cloudinary_signature_required:
24
+ return raw_body
25
+
26
+ if not settings.cloudinary_api_secret:
27
+ raise HTTPException(
28
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
29
+ detail="Cloudinary signature validation is required but CLOUDINARY_API_SECRET is not configured.",
30
+ )
31
+
32
+ signature = request.headers.get(SIGNATURE_HEADER)
33
+ timestamp = request.headers.get(TIMESTAMP_HEADER)
34
+ if not signature or not timestamp:
35
+ raise HTTPException(
36
+ status_code=status.HTTP_401_UNAUTHORIZED,
37
+ detail="Missing Cloudinary signature headers.",
38
+ )
39
+
40
+ try:
41
+ timestamp_int = int(timestamp)
42
+ except ValueError as exc:
43
+ raise HTTPException(
44
+ status_code=status.HTTP_401_UNAUTHORIZED,
45
+ detail="Invalid Cloudinary timestamp.",
46
+ ) from exc
47
+
48
+ age = abs(int(time.time()) - timestamp_int)
49
+ if age > settings.cloudinary_signature_tolerance_seconds:
50
+ raise HTTPException(
51
+ status_code=status.HTTP_401_UNAUTHORIZED,
52
+ detail="Expired Cloudinary signature.",
53
+ )
54
+
55
+ if not verify_notification_signature(raw_body, signature, timestamp, settings.cloudinary_api_secret):
56
+ raise HTTPException(
57
+ status_code=status.HTTP_401_UNAUTHORIZED,
58
+ detail="Invalid Cloudinary signature.",
59
+ )
60
+
61
+ return raw_body
app/config.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from functools import lru_cache
2
+
3
+ from pydantic import AnyHttpUrl, Field, PositiveInt, field_validator
4
+ from pydantic_settings import BaseSettings, SettingsConfigDict
5
+
6
+
7
+ class Settings(BaseSettings):
8
+ model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
9
+
10
+ app_name: str
11
+ log_level: str
12
+ webhook_path: str
13
+
14
+ ai_pipeline_url: AnyHttpUrl
15
+ ai_pipeline_timeout_seconds: PositiveInt
16
+
17
+ supabase_url: AnyHttpUrl
18
+ supabase_service_role_key: str = Field(repr=False)
19
+ supabase_table: str
20
+
21
+ cloudinary_api_secret: str = Field(repr=False)
22
+ cloudinary_signature_required: bool
23
+ cloudinary_signature_tolerance_seconds: PositiveInt
24
+
25
+ @field_validator("webhook_path")
26
+ @classmethod
27
+ def normalize_webhook_path(cls, value: str) -> str:
28
+ value = value.strip()
29
+ if not value:
30
+ raise ValueError("WEBHOOK_PATH cannot be empty")
31
+ return value if value.startswith("/") else f"/{value}"
32
+
33
+
34
+ @lru_cache
35
+ def get_settings() -> Settings:
36
+ return Settings()
app/main.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+
3
+ from fastapi import Depends, FastAPI, Request, status
4
+
5
+ from app.cloudinary import enforce_cloudinary_signature
6
+ from app.config import Settings, get_settings
7
+ from app.models import HealthResponse, WebhookAcceptedResponse
8
+ from app.services import PipelineClient, SupabaseClient
9
+ from app.workflow import parse_cloudinary_payload, run_webhook_workflow
10
+
11
+
12
+ def create_app() -> FastAPI:
13
+ settings = get_settings()
14
+ logging.basicConfig(level=settings.log_level.upper())
15
+
16
+ app = FastAPI(title=settings.app_name)
17
+
18
+ @app.get("/health", response_model=HealthResponse)
19
+ async def health(settings: Settings = Depends(get_settings)) -> HealthResponse:
20
+ return HealthResponse(status="ok", app=settings.app_name)
21
+
22
+ @app.post(
23
+ settings.webhook_path,
24
+ response_model=WebhookAcceptedResponse,
25
+ status_code=status.HTTP_202_ACCEPTED,
26
+ )
27
+ async def cloudinary_trigger(
28
+ request: Request,
29
+ settings: Settings = Depends(get_settings),
30
+ ) -> WebhookAcceptedResponse:
31
+ raw_body = await enforce_cloudinary_signature(request, settings)
32
+ parsed = parse_cloudinary_payload(raw_body)
33
+
34
+ if not parsed.id:
35
+ return WebhookAcceptedResponse(status="skipped", id=None)
36
+
37
+ pipeline_client = PipelineClient(settings)
38
+ supabase_client = SupabaseClient(settings)
39
+ await run_webhook_workflow(parsed, pipeline_client, supabase_client)
40
+
41
+ return WebhookAcceptedResponse(
42
+ status="complete",
43
+ id=parsed.id,
44
+ pipeline_url=settings.ai_pipeline_url,
45
+ )
46
+
47
+ return app
48
+
49
+
50
+ app = create_app()
app/models.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field, HttpUrl
4
+
5
+
6
+ class ParsedWebhookData(BaseModel):
7
+ id: str | None = None
8
+ latitude: float | None = None
9
+ longitude: float | None = None
10
+ description: str = ""
11
+ before_img_url: str = ""
12
+
13
+
14
+ class PipelineRequest(BaseModel):
15
+ lan: float
16
+ lon: float
17
+ img_location: str
18
+
19
+
20
+ class PipelineClassification(BaseModel):
21
+ model_config = ConfigDict(extra="allow")
22
+
23
+ condition: str
24
+
25
+
26
+ class PipelineResponse(BaseModel):
27
+ model_config = ConfigDict(extra="allow")
28
+
29
+ classification: PipelineClassification
30
+ priority_score: float
31
+ max_impact: float
32
+
33
+
34
+ class SupabaseUpdate(BaseModel):
35
+ destruct_class: str
36
+ location_score: float
37
+ total_score: float
38
+ status: str = "complete"
39
+
40
+
41
+ class WebhookEnvelope(BaseModel):
42
+ body: dict[str, Any] = Field(default_factory=dict)
43
+
44
+
45
+ class HealthResponse(BaseModel):
46
+ status: str
47
+ app: str
48
+
49
+
50
+ class WebhookAcceptedResponse(BaseModel):
51
+ status: str
52
+ id: str | None = None
53
+ pipeline_url: HttpUrl | None = None
app/services.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from urllib.parse import quote, urlparse
2
+
3
+ import httpx
4
+ from fastapi import HTTPException, status
5
+
6
+ from app.config import Settings
7
+ from app.models import PipelineRequest, PipelineResponse, SupabaseUpdate
8
+
9
+
10
+ class PipelineClient:
11
+ _image_field_names = ("image", "file", "img")
12
+
13
+ def __init__(self, settings: Settings) -> None:
14
+ self._base_url = str(settings.ai_pipeline_url).rstrip("/")
15
+ self._timeout = settings.ai_pipeline_timeout_seconds
16
+
17
+ async def process_url(self, payload: PipelineRequest) -> PipelineResponse:
18
+ async with httpx.AsyncClient(timeout=self._timeout) as client:
19
+ image = await self._download_image(client, payload.img_location)
20
+
21
+ try:
22
+ response = await self._post_process_form(client, payload, image)
23
+ except httpx.HTTPError as exc:
24
+ raise HTTPException(
25
+ status_code=status.HTTP_502_BAD_GATEWAY,
26
+ detail=f"AI pipeline request failed: {exc}",
27
+ ) from exc
28
+
29
+ return PipelineResponse.model_validate(response.json())
30
+
31
+ async def _download_image(self, client: httpx.AsyncClient, image_url: str) -> tuple[str, bytes, str]:
32
+ try:
33
+ response = await client.get(image_url, follow_redirects=True)
34
+ response.raise_for_status()
35
+ except httpx.HTTPError as exc:
36
+ raise HTTPException(
37
+ status_code=status.HTTP_502_BAD_GATEWAY,
38
+ detail=f"Failed to download Cloudinary image: {exc}",
39
+ ) from exc
40
+
41
+ content = response.content
42
+ if not content:
43
+ raise HTTPException(
44
+ status_code=status.HTTP_502_BAD_GATEWAY,
45
+ detail="Downloaded Cloudinary image is empty.",
46
+ )
47
+
48
+ content_type = response.headers.get("content-type", "application/octet-stream").split(";")[0]
49
+ filename = _filename_from_url(image_url)
50
+ return filename, content, content_type
51
+
52
+ async def _post_process_form(
53
+ self,
54
+ client: httpx.AsyncClient,
55
+ payload: PipelineRequest,
56
+ image: tuple[str, bytes, str],
57
+ ) -> httpx.Response:
58
+ last_response: httpx.Response | None = None
59
+ data = {
60
+ "lan": str(payload.lan),
61
+ "lon": str(payload.lon),
62
+ }
63
+
64
+ for field_name in self._image_field_names:
65
+ filename, content, content_type = image
66
+ files = {
67
+ field_name: (filename, content, content_type),
68
+ }
69
+ response = await client.post(f"{self._base_url}/process", data=data, files=files)
70
+ if response.status_code != 422:
71
+ response.raise_for_status()
72
+ return response
73
+
74
+ last_response = response
75
+
76
+ if last_response is not None:
77
+ last_response.raise_for_status()
78
+
79
+ raise HTTPException(
80
+ status_code=status.HTTP_502_BAD_GATEWAY,
81
+ detail="AI pipeline did not return a response.",
82
+ )
83
+
84
+
85
+ def _filename_from_url(image_url: str) -> str:
86
+ path = urlparse(image_url).path
87
+ filename = path.rsplit("/", 1)[-1].strip()
88
+ return filename or "cloudinary-image.jpg"
89
+
90
+
91
+ class SupabaseClient:
92
+ def __init__(self, settings: Settings) -> None:
93
+ if not settings.supabase_url or not settings.supabase_service_role_key:
94
+ raise HTTPException(
95
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
96
+ detail="Supabase is not configured.",
97
+ )
98
+
99
+ self._base_url = str(settings.supabase_url).rstrip("/")
100
+ self._table = settings.supabase_table
101
+ self._headers = {
102
+ "apikey": settings.supabase_service_role_key,
103
+ "Authorization": f"Bearer {settings.supabase_service_role_key}",
104
+ "Content-Type": "application/json",
105
+ "Prefer": "return=minimal",
106
+ }
107
+
108
+ async def update_report(self, report_id: str, payload: SupabaseUpdate) -> None:
109
+ encoded_id = quote(report_id, safe="")
110
+ url = f"{self._base_url}/rest/v1/{self._table}?id=eq.{encoded_id}"
111
+
112
+ async with httpx.AsyncClient(timeout=30) as client:
113
+ try:
114
+ response = await client.patch(url, headers=self._headers, json=payload.model_dump())
115
+ response.raise_for_status()
116
+ except httpx.HTTPError as exc:
117
+ raise HTTPException(
118
+ status_code=status.HTTP_502_BAD_GATEWAY,
119
+ detail=f"Supabase update failed: {exc}",
120
+ ) from exc
app/workflow.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from typing import Any
3
+
4
+ from fastapi import HTTPException, status
5
+
6
+ from app.models import ParsedWebhookData, PipelineRequest, PipelineResponse, SupabaseUpdate
7
+ from app.services import PipelineClient, SupabaseClient
8
+
9
+
10
+ def parse_cloudinary_payload(raw_body: bytes) -> ParsedWebhookData:
11
+ try:
12
+ payload = json.loads(raw_body.decode("utf-8"))
13
+ except json.JSONDecodeError as exc:
14
+ raise HTTPException(
15
+ status_code=status.HTTP_400_BAD_REQUEST,
16
+ detail="Invalid JSON payload.",
17
+ ) from exc
18
+
19
+ body = payload.get("body", payload)
20
+ if not isinstance(body, dict):
21
+ body = {}
22
+
23
+ context = _as_dict(body.get("context"))
24
+ custom = _as_dict(context.get("custom"))
25
+
26
+ return ParsedWebhookData(
27
+ id=custom.get("Id") or None,
28
+ latitude=_to_float_or_none(custom.get("latitude")),
29
+ longitude=_to_float_or_none(custom.get("longitude")),
30
+ description=custom.get("description") or "",
31
+ before_img_url=body.get("secure_url") or "",
32
+ )
33
+
34
+
35
+ async def run_webhook_workflow(
36
+ parsed: ParsedWebhookData,
37
+ pipeline_client: PipelineClient,
38
+ supabase_client: SupabaseClient,
39
+ ) -> PipelineResponse | None:
40
+ if not parsed.id:
41
+ return None
42
+
43
+ pipeline_response = await pipeline_client.process_url(
44
+ PipelineRequest(
45
+ lan=parsed.latitude,
46
+ lon=parsed.longitude,
47
+ img_location=parsed.before_img_url,
48
+ )
49
+ )
50
+
51
+ await supabase_client.update_report(
52
+ parsed.id,
53
+ SupabaseUpdate(
54
+ destruct_class=pipeline_response.classification.condition,
55
+ location_score=pipeline_response.priority_score,
56
+ total_score=pipeline_response.max_impact,
57
+ ),
58
+ )
59
+
60
+ return pipeline_response
61
+
62
+
63
+ def _as_dict(value: Any) -> dict[str, Any]:
64
+ return value if isinstance(value, dict) else {}
65
+
66
+
67
+ def _to_float_or_none(value: Any) -> float | None:
68
+ if value is None or value == "":
69
+ return None
70
+ try:
71
+ return float(value)
72
+ except (TypeError, ValueError):
73
+ return None
pyproject.toml ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "gungfi-webhook-be"
3
+ version = "0.1.0"
4
+ description = "FastAPI backend for forwarding Cloudinary webhooks to the AI pipeline and Supabase."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = [
8
+ "fastapi>=0.115.0",
9
+ "httpx>=0.27.0",
10
+ "pydantic-settings>=2.6.0",
11
+ "uvicorn[standard]>=0.30.0",
12
+ ]
13
+
14
+ [dependency-groups]
15
+ dev = [
16
+ "pytest>=8.3.0",
17
+ "respx>=0.21.1",
18
+ ]
19
+
20
+ [tool.pytest.ini_options]
21
+ testpaths = ["tests"]
22
+ pythonpath = ["."]
scripts/upload_cloudinary_test.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import hashlib
2
+ import os
3
+ import sys
4
+ import time
5
+ from pathlib import Path
6
+
7
+ import httpx
8
+ from dotenv import load_dotenv
9
+
10
+
11
+ def main() -> int:
12
+ load_dotenv()
13
+
14
+ image_path = Path(os.getenv("CLOUDINARY_TEST_IMAGE", "img/DSC06041.jpg"))
15
+ cloud_name = os.getenv("CLOUDINARY_CLOUD_NAME")
16
+ api_key = os.getenv("CLOUDINARY_API_KEY")
17
+ api_secret = os.getenv("CLOUDINARY_API_SECRET")
18
+ missing = [
19
+ name
20
+ for name, value in {
21
+ "CLOUDINARY_CLOUD_NAME": cloud_name,
22
+ "CLOUDINARY_API_KEY": api_key,
23
+ "CLOUDINARY_API_SECRET": api_secret,
24
+ }.items()
25
+ if not value
26
+ ]
27
+ if missing:
28
+ print(f"Missing env: {', '.join(missing)}", file=sys.stderr)
29
+ return 1
30
+
31
+ if not image_path.exists():
32
+ print(f"Image not found: {image_path}", file=sys.stderr)
33
+ return 1
34
+
35
+ timestamp = str(int(time.time()))
36
+ params: dict[str, str] = {
37
+ "api_key": api_key,
38
+ "context": "Id=test-fastapi-webhook|latitude=-6.200000|longitude=106.816666|description=fastapi webhook smoke test",
39
+ "timestamp": timestamp,
40
+ }
41
+
42
+ params["signature"] = sign_upload_params(params, api_secret)
43
+
44
+ url = f"https://api.cloudinary.com/v1_1/{cloud_name}/image/upload"
45
+ with image_path.open("rb") as image:
46
+ files = {"file": (image_path.name, image, "image/jpeg")}
47
+ response = httpx.post(url, data=params, files=files, timeout=120)
48
+
49
+ if response.is_error:
50
+ print(f"Cloudinary upload failed: HTTP {response.status_code}", file=sys.stderr)
51
+ print(response.text, file=sys.stderr)
52
+ return 1
53
+
54
+ data = response.json()
55
+ print("Upload OK")
56
+ print(f"public_id: {data.get('public_id')}")
57
+ print(f"secure_url: {data.get('secure_url')}")
58
+ return 0
59
+
60
+
61
+ def sign_upload_params(params: dict[str, str], api_secret: str) -> str:
62
+ signable = {
63
+ key: value
64
+ for key, value in params.items()
65
+ if key not in {"api_key", "file", "resource_type", "cloud_name", "signature"} and value
66
+ }
67
+ payload = "&".join(f"{key}={signable[key]}" for key in sorted(signable))
68
+ return hashlib.sha1(f"{payload}{api_secret}".encode("utf-8")).hexdigest()
69
+
70
+
71
+ if __name__ == "__main__":
72
+ raise SystemExit(main())
tests/test_workflow.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import hashlib
2
+ import json
3
+ import time
4
+
5
+ import httpx
6
+ import respx
7
+ from fastapi.testclient import TestClient
8
+
9
+ from app.cloudinary import verify_notification_signature
10
+ from app.config import Settings, get_settings
11
+ from app.main import create_app
12
+ from app.models import ParsedWebhookData, PipelineRequest
13
+ from app.services import PipelineClient
14
+ from app.workflow import parse_cloudinary_payload
15
+
16
+
17
+ def test_parse_cloudinary_payload_matches_n8n_template() -> None:
18
+ raw = json.dumps(
19
+ {
20
+ "body": {
21
+ "secure_url": "https://res.cloudinary.com/demo/image/upload/sample.jpg",
22
+ "context": {
23
+ "custom": {
24
+ "Id": "report-1",
25
+ "latitude": "-6.2",
26
+ "longitude": "106.8",
27
+ "description": "jalan rusak",
28
+ }
29
+ },
30
+ }
31
+ }
32
+ ).encode()
33
+
34
+ parsed = parse_cloudinary_payload(raw)
35
+
36
+ assert parsed.id == "report-1"
37
+ assert parsed.latitude == -6.2
38
+ assert parsed.longitude == 106.8
39
+ assert parsed.description == "jalan rusak"
40
+ assert parsed.before_img_url == "https://res.cloudinary.com/demo/image/upload/sample.jpg"
41
+
42
+
43
+ def test_cloudinary_signature_verification_uses_raw_body_timestamp_and_secret() -> None:
44
+ raw_body = b'{"public_id":"sample"}'
45
+ timestamp = "1315060510"
46
+ secret = "abcd"
47
+ signature = hashlib.sha1(raw_body + timestamp.encode() + secret.encode()).hexdigest()
48
+
49
+ assert verify_notification_signature(raw_body, signature, timestamp, secret)
50
+
51
+
52
+ def test_webhook_skips_missing_id_without_external_calls() -> None:
53
+ app = create_app()
54
+ app.dependency_overrides[get_settings] = lambda: Settings(
55
+ app_name="gungfi-webhook-be",
56
+ log_level="INFO",
57
+ webhook_path="/cloudinary-trigger",
58
+ ai_pipeline_url="https://pipeline.example.com",
59
+ ai_pipeline_timeout_seconds=60,
60
+ cloudinary_api_secret="secret",
61
+ cloudinary_signature_required=True,
62
+ cloudinary_signature_tolerance_seconds=7200,
63
+ supabase_url="https://example.supabase.co",
64
+ supabase_service_role_key="service-role",
65
+ supabase_table="reports",
66
+ )
67
+ client = TestClient(app)
68
+ body = json.dumps({"body": {"context": {"custom": {}}, "secure_url": ""}}, separators=(",", ":")).encode()
69
+ timestamp = str(int(time.time()))
70
+ signature = hashlib.sha1(body + timestamp.encode() + b"secret").hexdigest()
71
+
72
+ response = client.post(
73
+ "/cloudinary-trigger",
74
+ content=body,
75
+ headers={
76
+ "content-type": "application/json",
77
+ "x-cld-signature": signature,
78
+ "x-cld-timestamp": timestamp,
79
+ },
80
+ )
81
+
82
+ assert response.status_code == 202
83
+ assert response.json() == {"status": "skipped", "id": None, "pipeline_url": None}
84
+
85
+
86
+ def test_webhook_workflow_sends_process_payload() -> None:
87
+ from app.workflow import run_webhook_workflow
88
+
89
+ class FakePipelineClient:
90
+ payload = None
91
+
92
+ async def process_url(self, payload):
93
+ self.payload = payload
94
+ return type(
95
+ "PipelineResponse",
96
+ (),
97
+ {
98
+ "classification": type("Classification", (), {"condition": "rusak"})(),
99
+ "priority_score": 0.8,
100
+ "max_impact": 0.9,
101
+ },
102
+ )()
103
+
104
+ class FakeSupabaseClient:
105
+ async def update_report(self, report_id, payload):
106
+ self.report_id = report_id
107
+ self.payload = payload
108
+
109
+ pipeline_client = FakePipelineClient()
110
+ supabase_client = FakeSupabaseClient()
111
+
112
+ import anyio
113
+
114
+ anyio.run(
115
+ run_webhook_workflow,
116
+ ParsedWebhookData(
117
+ id="report-1",
118
+ latitude=-6.2,
119
+ longitude=106.8,
120
+ before_img_url="https://res.cloudinary.com/demo/image/upload/sample.jpg",
121
+ ),
122
+ pipeline_client,
123
+ supabase_client,
124
+ )
125
+
126
+ assert pipeline_client.payload.model_dump() == {
127
+ "lan": -6.2,
128
+ "lon": 106.8,
129
+ "img_location": "https://res.cloudinary.com/demo/image/upload/sample.jpg",
130
+ }
131
+
132
+
133
+ @respx.mock
134
+ def test_pipeline_client_downloads_image_and_posts_multipart_to_process() -> None:
135
+ settings = Settings(
136
+ app_name="gungfi-webhook-be",
137
+ log_level="INFO",
138
+ webhook_path="/cloudinary-trigger",
139
+ ai_pipeline_url="https://pipeline.example.com",
140
+ ai_pipeline_timeout_seconds=60,
141
+ cloudinary_api_secret="secret",
142
+ cloudinary_signature_required=True,
143
+ cloudinary_signature_tolerance_seconds=7200,
144
+ supabase_url="https://example.supabase.co",
145
+ supabase_service_role_key="service-role",
146
+ supabase_table="reports",
147
+ )
148
+ image_route = respx.get("https://res.cloudinary.com/demo/image/upload/sample.jpg").mock(
149
+ return_value=httpx.Response(200, content=b"fake-image-bytes", headers={"content-type": "image/jpeg"})
150
+ )
151
+
152
+ def handle_process_request(request: httpx.Request) -> httpx.Response:
153
+ body = request.content
154
+ assert "multipart/form-data" in request.headers["content-type"]
155
+ assert b'name="lan"' in body
156
+ assert b"-6.2" in body
157
+ assert b'name="lon"' in body
158
+ assert b"106.8" in body
159
+ assert b'name="image"; filename="sample.jpg"' in body
160
+ assert b"fake-image-bytes" in body
161
+ return httpx.Response(
162
+ 200,
163
+ json={
164
+ "classification": {"condition": "rusak", "confidence": 0.9, "probabilities": {}},
165
+ "priority_score": 0.8,
166
+ "max_impact": 0.9,
167
+ },
168
+ )
169
+
170
+ route = respx.post("https://pipeline.example.com/process").mock(
171
+ side_effect=handle_process_request,
172
+ )
173
+
174
+ import anyio
175
+
176
+ anyio.run(
177
+ PipelineClient(settings).process_url,
178
+ PipelineRequest(
179
+ lan=-6.2,
180
+ lon=106.8,
181
+ img_location="https://res.cloudinary.com/demo/image/upload/sample.jpg",
182
+ ),
183
+ )
184
+
185
+ assert image_route.called
186
+ assert route.called
uv.lock ADDED
The diff for this file is too large to render. See raw diff