validops-east-1 commited on
Commit
fe8cd16
·
1 Parent(s): 0e4591f

feat: gcs impl

Browse files
app/api/server.py CHANGED
@@ -109,6 +109,8 @@ async def lifespan(app: FastAPI):
109
  await close_oauth_service()
110
  from app.api.v1.google_maps import close_maps_service
111
  await close_maps_service()
 
 
112
  from app.services.supabase import get_supabase_client
113
  client = get_supabase_client()
114
  if client:
 
109
  await close_oauth_service()
110
  from app.api.v1.google_maps import close_maps_service
111
  await close_maps_service()
112
+ from app.api.v1.gcs import close_gcs_service
113
+ await close_gcs_service()
114
  from app.services.supabase import get_supabase_client
115
  client = get_supabase_client()
116
  if client:
app/api/v1/gcs.py ADDED
@@ -0,0 +1,1024 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import re
6
+ import time
7
+ from typing import Any, Dict, Optional
8
+
9
+ from fastapi import APIRouter, Depends, File, Form, Header, HTTPException, Query, Response, UploadFile
10
+
11
+ from app.config import get_settings
12
+ from app.core.logger import get_logger
13
+ from app.models.schemas import (
14
+ GCSACLRequest,
15
+ GCSBaseCredentialsRequest,
16
+ GCSBucketIAMRequest,
17
+ GCSBucketRequest,
18
+ GCSBucketRestoreRequest,
19
+ GCSBucketUpdateRequest,
20
+ GCSComposeRequest,
21
+ GCSDownloadResponse,
22
+ GCSGenericResponse,
23
+ GCSListResponse,
24
+ GCSObjectTransferRequest,
25
+ GCSObjectUpdateRequest,
26
+ GCSResumableUploadRequest,
27
+ GCSURLRequest,
28
+ GCSURLResponse,
29
+ GCSWatchRequest,
30
+ )
31
+ from app.services.gcs_service import GCSCredentials, GCSCredentialsError, GCSError, GCSService
32
+
33
+ router = APIRouter(prefix="/google/gcs", tags=["Google Cloud Storage"])
34
+ _logger = get_logger(__name__)
35
+ _settings = get_settings()
36
+
37
+ _MAX_UPLOAD_BYTES = _settings.max_upload_bytes
38
+
39
+ _gcs_service = GCSService()
40
+
41
+
42
+ def get_gcs_service() -> GCSService:
43
+ return _gcs_service
44
+
45
+
46
+ async def close_gcs_service() -> None:
47
+ await _gcs_service.close()
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Shared helpers
52
+ # ---------------------------------------------------------------------------
53
+
54
+ def _elapsed_ms(start: float) -> float:
55
+ return round((time.perf_counter() - start) * 1000, 2)
56
+
57
+
58
+ async def _resolve(
59
+ service: GCSService,
60
+ *,
61
+ payload: Optional[Any] = None,
62
+ url: Optional[str] = None,
63
+ file_bytes: Optional[bytes] = None,
64
+ file_name: Optional[str] = None,
65
+ ) -> GCSCredentials:
66
+ try:
67
+ return await service.resolve_credentials(
68
+ payload=payload, url=url, file_bytes=file_bytes, file_name=file_name
69
+ )
70
+ except GCSCredentialsError as exc:
71
+ raise HTTPException(status_code=400, detail=exc.message)
72
+
73
+
74
+ def _creds_fields(body: Any) -> Dict[str, Any]:
75
+ """Extract credential kwargs from a GCS request model."""
76
+ payload = body.credentials if isinstance(body.credentials, dict) else body.credentials_json
77
+ return {"payload": payload, "url": body.credentials_url}
78
+
79
+
80
+ def _error_response(cls, start: float, exc: GCSError):
81
+ return cls(success=False, time_ms=_elapsed_ms(start), error=exc.message)
82
+
83
+
84
+ async def _read_file_limited(f: UploadFile, *, what: str) -> bytes:
85
+ data = await f.read()
86
+ if len(data) > _MAX_UPLOAD_BYTES:
87
+ raise HTTPException(
88
+ status_code=413,
89
+ detail=f"{what} exceeds the {_MAX_UPLOAD_BYTES} byte upload limit",
90
+ )
91
+ return data
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # Buckets — CRUD
96
+ # ---------------------------------------------------------------------------
97
+
98
+ @router.post("/buckets", response_model=GCSGenericResponse,
99
+ summary="Create a Cloud Storage bucket")
100
+ async def create_bucket(
101
+ body: GCSBucketRequest,
102
+ service: GCSService = Depends(get_gcs_service),
103
+ ):
104
+ start = time.perf_counter()
105
+ creds = await _resolve(service, **_creds_fields(body))
106
+ bucket_body: Dict[str, Any] = dict(body.bucket or {})
107
+ if body.location:
108
+ bucket_body["location"] = body.location
109
+ if body.storage_class:
110
+ bucket_body["storageClass"] = body.storage_class
111
+ try:
112
+ result = await service.create_bucket(creds, body.name, project=body.project_id, bucket_body=bucket_body)
113
+ return GCSGenericResponse(success=True, time_ms=_elapsed_ms(start), data=result.get("data"))
114
+ except GCSError as exc:
115
+ return _error_response(GCSGenericResponse, start, exc)
116
+
117
+
118
+ @router.get("/buckets/{bucket}", response_model=GCSGenericResponse,
119
+ summary="Get bucket metadata")
120
+ async def get_bucket(
121
+ bucket: str,
122
+ service: GCSService = Depends(get_gcs_service),
123
+ x_credentials: Optional[str] = Header(None, alias="X-Goog-Credentials", description="Service account JSON (or JSON string)"),
124
+ x_credentials_url: Optional[str] = Header(None, alias="X-Goog-Credentials-Url", description="URL to a service account JSON file"),
125
+ ):
126
+ start = time.perf_counter()
127
+ creds = await _resolve(service, payload=x_credentials, url=x_credentials_url)
128
+ try:
129
+ result = await service.get_bucket(creds, bucket)
130
+ return GCSGenericResponse(success=True, time_ms=_elapsed_ms(start), data=result.get("data"))
131
+ except GCSError as exc:
132
+ return _error_response(GCSGenericResponse, start, exc)
133
+
134
+
135
+ @router.get("/buckets", response_model=GCSListResponse,
136
+ summary="List buckets in a project")
137
+ async def list_buckets(
138
+ project_id: Optional[str] = Query(None, description="GCP project id (defaults to the service account project)"),
139
+ prefix: Optional[str] = Query(None, description="Filter buckets whose names start with this prefix"),
140
+ max_results: Optional[int] = Query(None, ge=1, le=1000, description="Maximum number of results to return"),
141
+ page_token: Optional[str] = Query(None, description="Pagination token from a previous response"),
142
+ service: GCSService = Depends(get_gcs_service),
143
+ x_credentials: Optional[str] = Header(None, alias="X-Goog-Credentials", description="Service account JSON (or JSON string)"),
144
+ x_credentials_url: Optional[str] = Header(None, alias="X-Goog-Credentials-Url", description="URL to a service account JSON file"),
145
+ ):
146
+ start = time.perf_counter()
147
+ creds = await _resolve(service, payload=x_credentials, url=x_credentials_url)
148
+ try:
149
+ result = await service.list_buckets(creds, project=project_id, prefix=prefix,
150
+ max_results=max_results, page_token=page_token)
151
+ data = result.get("data") or {}
152
+ items = data.get("items", [])
153
+ return GCSListResponse(
154
+ success=True, time_ms=_elapsed_ms(start), count=len(items),
155
+ next_page_token=data.get("nextPageToken"),
156
+ items=items, prefixes=[],
157
+ )
158
+ except GCSError as exc:
159
+ return _error_response(GCSListResponse, start, exc)
160
+
161
+
162
+ @router.patch("/buckets/{bucket}", response_model=GCSGenericResponse,
163
+ summary="Patch bucket metadata (partial update)")
164
+ async def patch_bucket(
165
+ bucket: str,
166
+ body: GCSBucketUpdateRequest,
167
+ service: GCSService = Depends(get_gcs_service),
168
+ ):
169
+ start = time.perf_counter()
170
+ creds = await _resolve(service, **_creds_fields(body))
171
+ try:
172
+ result = await service.patch_bucket(creds, bucket, body.bucket)
173
+ return GCSGenericResponse(success=True, time_ms=_elapsed_ms(start), data=result.get("data"))
174
+ except GCSError as exc:
175
+ return _error_response(GCSGenericResponse, start, exc)
176
+
177
+
178
+ @router.put("/buckets/{bucket}", response_model=GCSGenericResponse,
179
+ summary="Replace bucket metadata (full update)")
180
+ async def update_bucket(
181
+ bucket: str,
182
+ body: GCSBucketUpdateRequest,
183
+ service: GCSService = Depends(get_gcs_service),
184
+ ):
185
+ start = time.perf_counter()
186
+ creds = await _resolve(service, **_creds_fields(body))
187
+ try:
188
+ result = await service.update_bucket(creds, bucket, body.bucket)
189
+ return GCSGenericResponse(success=True, time_ms=_elapsed_ms(start), data=result.get("data"))
190
+ except GCSError as exc:
191
+ return _error_response(GCSGenericResponse, start, exc)
192
+
193
+
194
+ @router.delete("/buckets/{bucket}", response_model=GCSGenericResponse,
195
+ summary="Delete a bucket")
196
+ async def delete_bucket(
197
+ bucket: str,
198
+ body: Optional[Dict[str, Any]] = None,
199
+ service: GCSService = Depends(get_gcs_service),
200
+ ):
201
+ start = time.perf_counter()
202
+ creds = await _resolve(
203
+ service,
204
+ payload=(body or {}).get("credentials_json") or (body or {}).get("credentials"),
205
+ url=(body or {}).get("credentials_url"),
206
+ )
207
+ try:
208
+ await service.delete_bucket(creds, bucket)
209
+ return GCSGenericResponse(success=True, time_ms=_elapsed_ms(start), data={"deleted": bucket})
210
+ except GCSError as exc:
211
+ return _error_response(GCSGenericResponse, start, exc)
212
+
213
+
214
+ # ---------------------------------------------------------------------------
215
+ # Buckets — IAM & permissions
216
+ # ---------------------------------------------------------------------------
217
+
218
+ @router.post("/buckets/{bucket}/restore", response_model=GCSGenericResponse,
219
+ summary="Restore a soft-deleted bucket")
220
+ async def restore_bucket(
221
+ bucket: str,
222
+ body: GCSBucketRestoreRequest,
223
+ service: GCSService = Depends(get_gcs_service),
224
+ ):
225
+ start = time.perf_counter()
226
+ creds = await _resolve(service, **_creds_fields(body))
227
+ try:
228
+ result = await service.restore_bucket(creds, bucket, if_metageneration_match=body.if_metageneration_match)
229
+ return GCSGenericResponse(success=True, time_ms=_elapsed_ms(start), data=result.get("data"))
230
+ except GCSError as exc:
231
+ return _error_response(GCSGenericResponse, start, exc)
232
+
233
+
234
+ @router.get("/buckets/{bucket}/iam", response_model=GCSGenericResponse,
235
+ summary="Get bucket IAM policy")
236
+ async def get_bucket_iam(
237
+ bucket: str,
238
+ service: GCSService = Depends(get_gcs_service),
239
+ x_credentials: Optional[str] = Header(None, alias="X-Goog-Credentials"),
240
+ x_credentials_url: Optional[str] = Header(None, alias="X-Goog-Credentials-Url"),
241
+ ):
242
+ start = time.perf_counter()
243
+ creds = await _resolve(service, payload=x_credentials, url=x_credentials_url)
244
+ try:
245
+ result = await service.get_bucket_iam(creds, bucket)
246
+ return GCSGenericResponse(success=True, time_ms=_elapsed_ms(start), data=result.get("data"))
247
+ except GCSError as exc:
248
+ return _error_response(GCSGenericResponse, start, exc)
249
+
250
+
251
+ @router.put("/buckets/{bucket}/iam", response_model=GCSGenericResponse,
252
+ summary="Set bucket IAM policy")
253
+ async def set_bucket_iam(
254
+ bucket: str,
255
+ body: GCSBucketIAMRequest,
256
+ service: GCSService = Depends(get_gcs_service),
257
+ ):
258
+ start = time.perf_counter()
259
+ creds = await _resolve(service, **_creds_fields(body))
260
+ try:
261
+ result = await service.set_bucket_iam(creds, bucket, body.policy)
262
+ return GCSGenericResponse(success=True, time_ms=_elapsed_ms(start), data=result.get("data"))
263
+ except GCSError as exc:
264
+ return _error_response(GCSGenericResponse, start, exc)
265
+
266
+
267
+ @router.get("/buckets/{bucket}/iam/testPermissions", response_model=GCSGenericResponse,
268
+ summary="Test permissions on a bucket")
269
+ async def test_bucket_permissions(
270
+ bucket: str,
271
+ permissions: str = Query(..., description="Comma-separated permissions to test, e.g. 'storage.buckets.get,storage.objects.list'"),
272
+ service: GCSService = Depends(get_gcs_service),
273
+ x_credentials: Optional[str] = Header(None, alias="X-Goog-Credentials"),
274
+ x_credentials_url: Optional[str] = Header(None, alias="X-Goog-Credentials-Url"),
275
+ ):
276
+ start = time.perf_counter()
277
+ creds = await _resolve(service, payload=x_credentials, url=x_credentials_url)
278
+ permission_list = [p.strip() for p in permissions.split(",") if p.strip()]
279
+ if not permission_list:
280
+ raise HTTPException(status_code=422, detail="At least one permission is required.")
281
+ try:
282
+ result = await service.test_bucket_permissions(creds, bucket, permission_list)
283
+ return GCSGenericResponse(success=True, time_ms=_elapsed_ms(start), data=result.get("data"))
284
+ except GCSError as exc:
285
+ return _error_response(GCSGenericResponse, start, exc)
286
+
287
+
288
+ # ---------------------------------------------------------------------------
289
+ # Buckets — default object ACLs
290
+ # ---------------------------------------------------------------------------
291
+
292
+ @router.get("/buckets/{bucket}/defaultAcl", response_model=GCSGenericResponse,
293
+ summary="List default object ACLs")
294
+ async def list_default_object_acl(
295
+ bucket: str,
296
+ service: GCSService = Depends(get_gcs_service),
297
+ x_credentials: Optional[str] = Header(None, alias="X-Goog-Credentials"),
298
+ x_credentials_url: Optional[str] = Header(None, alias="X-Goog-Credentials-Url"),
299
+ ):
300
+ start = time.perf_counter()
301
+ creds = await _resolve(service, payload=x_credentials, url=x_credentials_url)
302
+ try:
303
+ result = await service.list_default_object_acl(creds, bucket)
304
+ return GCSGenericResponse(success=True, time_ms=_elapsed_ms(start), data=result.get("data"))
305
+ except GCSError as exc:
306
+ return _error_response(GCSGenericResponse, start, exc)
307
+
308
+
309
+ @router.post("/buckets/{bucket}/defaultAcl", response_model=GCSGenericResponse,
310
+ summary="Add a default object ACL entry")
311
+ async def insert_default_object_acl(
312
+ bucket: str,
313
+ body: GCSACLRequest,
314
+ service: GCSService = Depends(get_gcs_service),
315
+ ):
316
+ start = time.perf_counter()
317
+ creds = await _resolve(service, **_creds_fields(body))
318
+ acl = {"entity": body.entity, "role": body.role}
319
+ try:
320
+ result = await service.insert_default_object_acl(creds, bucket, acl)
321
+ return GCSGenericResponse(success=True, time_ms=_elapsed_ms(start), data=result.get("data"))
322
+ except GCSError as exc:
323
+ return _error_response(GCSGenericResponse, start, exc)
324
+
325
+
326
+ @router.get("/buckets/{bucket}/defaultAcl/{entity}", response_model=GCSGenericResponse,
327
+ summary="Get a default object ACL entry")
328
+ async def get_default_object_acl(
329
+ bucket: str,
330
+ entity: str,
331
+ service: GCSService = Depends(get_gcs_service),
332
+ x_credentials: Optional[str] = Header(None, alias="X-Goog-Credentials"),
333
+ x_credentials_url: Optional[str] = Header(None, alias="X-Goog-Credentials-Url"),
334
+ ):
335
+ start = time.perf_counter()
336
+ creds = await _resolve(service, payload=x_credentials, url=x_credentials_url)
337
+ try:
338
+ result = await service.get_default_object_acl(creds, bucket, entity)
339
+ return GCSGenericResponse(success=True, time_ms=_elapsed_ms(start), data=result.get("data"))
340
+ except GCSError as exc:
341
+ return _error_response(GCSGenericResponse, start, exc)
342
+
343
+
344
+ @router.patch("/buckets/{bucket}/defaultAcl/{entity}", response_model=GCSGenericResponse,
345
+ summary="Patch a default object ACL entry")
346
+ async def patch_default_object_acl(
347
+ bucket: str,
348
+ entity: str,
349
+ body: GCSACLRequest,
350
+ service: GCSService = Depends(get_gcs_service),
351
+ ):
352
+ start = time.perf_counter()
353
+ creds = await _resolve(service, **_creds_fields(body))
354
+ acl = {"entity": body.entity, "role": body.role}
355
+ try:
356
+ result = await service.patch_default_object_acl(creds, bucket, entity, acl)
357
+ return GCSGenericResponse(success=True, time_ms=_elapsed_ms(start), data=result.get("data"))
358
+ except GCSError as exc:
359
+ return _error_response(GCSGenericResponse, start, exc)
360
+
361
+
362
+ @router.put("/buckets/{bucket}/defaultAcl/{entity}", response_model=GCSGenericResponse,
363
+ summary="Update a default object ACL entry")
364
+ async def update_default_object_acl(
365
+ bucket: str,
366
+ entity: str,
367
+ body: GCSACLRequest,
368
+ service: GCSService = Depends(get_gcs_service),
369
+ ):
370
+ start = time.perf_counter()
371
+ creds = await _resolve(service, **_creds_fields(body))
372
+ acl = {"entity": body.entity, "role": body.role}
373
+ try:
374
+ result = await service.update_default_object_acl(creds, bucket, entity, acl)
375
+ return GCSGenericResponse(success=True, time_ms=_elapsed_ms(start), data=result.get("data"))
376
+ except GCSError as exc:
377
+ return _error_response(GCSGenericResponse, start, exc)
378
+
379
+
380
+ @router.delete("/buckets/{bucket}/defaultAcl/{entity}", response_model=GCSGenericResponse,
381
+ summary="Delete a default object ACL entry")
382
+ async def delete_default_object_acl(
383
+ bucket: str,
384
+ entity: str,
385
+ body: Optional[Dict[str, Any]] = None,
386
+ service: GCSService = Depends(get_gcs_service),
387
+ ):
388
+ start = time.perf_counter()
389
+ creds = await _resolve(
390
+ service,
391
+ payload=(body or {}).get("credentials_json") or (body or {}).get("credentials"),
392
+ url=(body or {}).get("credentials_url"),
393
+ )
394
+ try:
395
+ await service.delete_default_object_acl(creds, bucket, entity)
396
+ return GCSGenericResponse(success=True, time_ms=_elapsed_ms(start), data={"deleted": entity})
397
+ except GCSError as exc:
398
+ return _error_response(GCSGenericResponse, start, exc)
399
+
400
+
401
+ # ---------------------------------------------------------------------------
402
+ # Objects — list / upload / download
403
+ # ---------------------------------------------------------------------------
404
+
405
+ @router.get("/buckets/{bucket}/objects", response_model=GCSListResponse,
406
+ summary="List objects in a bucket")
407
+ async def list_objects(
408
+ bucket: str,
409
+ prefix: Optional[str] = Query(None, description="Filter objects whose names start with this prefix"),
410
+ delimiter: Optional[str] = Query(None, description="Fold object names at this delimiter (e.g. '/')"),
411
+ max_results: Optional[int] = Query(None, ge=1, le=1000, description="Maximum number of results"),
412
+ page_token: Optional[str] = Query(None, description="Pagination token from a previous response"),
413
+ versions: Optional[bool] = Query(None, description="Include object versions"),
414
+ match_glob: Optional[str] = Query(None, description="Glob pattern to filter object names"),
415
+ start_offset: Optional[str] = Query(None, description="Lexicographically first object to return"),
416
+ end_offset: Optional[str] = Query(None, description="Lexicographically last object to return"),
417
+ service: GCSService = Depends(get_gcs_service),
418
+ x_credentials: Optional[str] = Header(None, alias="X-Goog-Credentials"),
419
+ x_credentials_url: Optional[str] = Header(None, alias="X-Goog-Credentials-Url"),
420
+ ):
421
+ start = time.perf_counter()
422
+ creds = await _resolve(service, payload=x_credentials, url=x_credentials_url)
423
+ try:
424
+ result = await service.list_objects(
425
+ creds, bucket, prefix=prefix, delimiter=delimiter, max_results=max_results,
426
+ page_token=page_token, versions=versions, match_glob=match_glob,
427
+ start_offset=start_offset, end_offset=end_offset,
428
+ )
429
+ data = result.get("data") or {}
430
+ items = data.get("items", [])
431
+ return GCSListResponse(
432
+ success=True, time_ms=_elapsed_ms(start), count=len(items),
433
+ next_page_token=data.get("nextPageToken"),
434
+ items=items, prefixes=data.get("prefixes", []),
435
+ )
436
+ except GCSError as exc:
437
+ return _error_response(GCSListResponse, start, exc)
438
+
439
+
440
+ @router.post("/buckets/{bucket}/objects/upload", response_model=GCSGenericResponse,
441
+ summary="Upload an object from a file or a URL (up to the server upload limit)")
442
+ async def upload_object(
443
+ bucket: str,
444
+ name: str = Form(..., min_length=1, max_length=1024, description="Destination object name"),
445
+ file: Optional[UploadFile] = File(None, description="Object content to upload"),
446
+ file_url: Optional[str] = Form(None, description="Publicly reachable URL whose content becomes the object"),
447
+ content_type: str = Form("application/octet-stream", description="Content-Type of the uploaded object"),
448
+ metadata_json: str = Form(None, description="Optional JSON object of object metadata to set on upload"),
449
+ if_generation_match: Optional[int] = Form(None, description="Generation match condition"),
450
+ credentials_file: Optional[UploadFile] = File(None, description="Service account JSON file (alternative to inline credentials)"),
451
+ credentials_json: Optional[str] = Form(None, description="Service account JSON as a string"),
452
+ credentials_url: Optional[str] = Form(None, description="URL to a service account JSON file"),
453
+ service: GCSService = Depends(get_gcs_service),
454
+ ):
455
+ start = time.perf_counter()
456
+
457
+ if file is None and not file_url:
458
+ raise HTTPException(status_code=422, detail="Either 'file' or 'file_url' must be provided to upload an object.")
459
+ if file is not None and file_url:
460
+ raise HTTPException(status_code=422, detail="Provide either 'file' or 'file_url', not both.")
461
+ if not re.fullmatch(r"[\w./+-]{1,255}", content_type):
462
+ raise HTTPException(status_code=422, detail="content_type must be a valid media type.")
463
+ cleaned_name = name.strip()
464
+ if not cleaned_name or cleaned_name.startswith("/"):
465
+ raise HTTPException(status_code=422, detail="Object name must be non-empty and must not start with '/'.")
466
+
467
+ if credentials_file is not None:
468
+ cred_bytes = await _read_file_limited(credentials_file, what="credentials file")
469
+ creds = await _resolve(service, file_bytes=cred_bytes, file_name=credentials_file.filename)
470
+ else:
471
+ creds = await _resolve(service, payload=credentials_json, url=credentials_url)
472
+
473
+ if file_url:
474
+ try:
475
+ content, fetched_type = await service.fetch_url_content(file_url)
476
+ except GCSError as exc:
477
+ raise HTTPException(status_code=exc.status_code, detail=exc.message)
478
+ if len(content) > _MAX_UPLOAD_BYTES:
479
+ raise HTTPException(status_code=413, detail=f"Content from file_url exceeds the {_MAX_UPLOAD_BYTES} byte upload limit")
480
+ if content_type == "application/octet-stream":
481
+ content_type = fetched_type or content_type
482
+ else:
483
+ content = await _read_file_limited(file, what="file")
484
+
485
+ metadata: Dict[str, Any] = {}
486
+ if metadata_json:
487
+ try:
488
+ metadata = json.loads(metadata_json)
489
+ if not isinstance(metadata, dict):
490
+ raise ValueError("metadata_json must be a JSON object")
491
+ except (json.JSONDecodeError, ValueError) as exc:
492
+ raise HTTPException(status_code=400, detail=f"Invalid metadata_json: {exc}")
493
+
494
+ try:
495
+ result = await service.upload_object(
496
+ creds, bucket, cleaned_name, content,
497
+ content_type=content_type, metadata=metadata,
498
+ if_generation_match=if_generation_match,
499
+ )
500
+ return GCSGenericResponse(success=True, time_ms=_elapsed_ms(start), data=result.get("data"))
501
+ except GCSError as exc:
502
+ return _error_response(GCSGenericResponse, start, exc)
503
+
504
+
505
+ @router.post("/buckets/{bucket}/objects/upload/resumable", response_model=GCSGenericResponse,
506
+ summary="Start a resumable upload session and return its upload URI")
507
+ async def initiate_resumable_upload(
508
+ bucket: str,
509
+ body: GCSResumableUploadRequest,
510
+ service: GCSService = Depends(get_gcs_service),
511
+ ):
512
+ start = time.perf_counter()
513
+ creds = await _resolve(service, **_creds_fields(body))
514
+ try:
515
+ result = await service.initiate_resumable_upload(
516
+ creds, bucket, body.name,
517
+ content_type=body.content_type or "application/octet-stream",
518
+ object_metadata=body.object_metadata,
519
+ )
520
+ return GCSGenericResponse(success=True, time_ms=_elapsed_ms(start), data={"session_uri": result.get("session_uri")})
521
+ except GCSError as exc:
522
+ return _error_response(GCSGenericResponse, start, exc)
523
+
524
+
525
+ @router.post("/buckets/{bucket}/objects/watch", response_model=GCSGenericResponse,
526
+ summary="Register an object-change notification channel for a bucket")
527
+ async def watch_all_objects(
528
+ bucket: str,
529
+ body: GCSWatchRequest,
530
+ service: GCSService = Depends(get_gcs_service),
531
+ ):
532
+ start = time.perf_counter()
533
+ creds = await _resolve(service, **_creds_fields(body))
534
+ try:
535
+ result = await service.watch_all_objects(creds, bucket, body.channel)
536
+ return GCSGenericResponse(success=True, time_ms=_elapsed_ms(start), data=result.get("data"))
537
+ except GCSError as exc:
538
+ return _error_response(GCSGenericResponse, start, exc)
539
+
540
+
541
+ @router.get("/buckets/{bucket}/objects/{object:path}/download", include_in_schema=False)
542
+ async def download_object_raw(
543
+ bucket: str,
544
+ object: str,
545
+ generation: Optional[int] = Query(None, description="Specific object generation to download"),
546
+ service: GCSService = Depends(get_gcs_service),
547
+ x_credentials: Optional[str] = Header(None, alias="X-Goog-Credentials"),
548
+ x_credentials_url: Optional[str] = Header(None, alias="X-Goog-Credentials-Url"),
549
+ ):
550
+ """Download an object's raw bytes (returns the file directly)."""
551
+ creds = await _resolve(service, payload=x_credentials, url=x_credentials_url)
552
+ try:
553
+ result = await service.download_object(creds, bucket, object, generation=generation)
554
+ except GCSError as exc:
555
+ raise HTTPException(status_code=exc.status_code, detail=exc.message)
556
+ content = result.get("data")
557
+ if not isinstance(content, bytes):
558
+ content = content or b""
559
+ content_type = result.get("content_type") or "application/octet-stream"
560
+ return Response(
561
+ content=content,
562
+ media_type=content_type,
563
+ headers={"Content-Disposition": f'attachment; filename="{object.split("/")[-1]}"'},
564
+ )
565
+
566
+
567
+ @router.get("/buckets/{bucket}/objects/{object:path}/download/base64", response_model=GCSDownloadResponse,
568
+ summary="Download an object as base64 (JSON response)")
569
+ async def download_object_base64(
570
+ bucket: str,
571
+ object: str,
572
+ generation: Optional[int] = Query(None, description="Specific object generation to download"),
573
+ service: GCSService = Depends(get_gcs_service),
574
+ x_credentials: Optional[str] = Header(None, alias="X-Goog-Credentials"),
575
+ x_credentials_url: Optional[str] = Header(None, alias="X-Goog-Credentials-Url"),
576
+ ):
577
+ start = time.perf_counter()
578
+ creds = await _resolve(service, payload=x_credentials, url=x_credentials_url)
579
+ try:
580
+ result = await service.download_object(creds, bucket, object, generation=generation)
581
+ except GCSError as exc:
582
+ return _error_response(GCSDownloadResponse, start, exc)
583
+ content = result.get("data")
584
+ content_bytes = content if isinstance(content, bytes) else (content or b"")
585
+ return GCSDownloadResponse(
586
+ success=True, time_ms=_elapsed_ms(start),
587
+ content_base64=base64.b64encode(content_bytes).decode("ascii"),
588
+ content_type=result.get("content_type"),
589
+ size_bytes=len(content_bytes),
590
+ )
591
+
592
+
593
+ @router.get("/buckets/{bucket}/objects/{object:path}/public-url", response_model=GCSURLResponse,
594
+ summary="Generate the public URL for an object")
595
+ async def public_url(
596
+ bucket: str,
597
+ object: str,
598
+ ):
599
+ start = time.perf_counter()
600
+ url = GCSService.public_url(bucket, object)
601
+ return GCSURLResponse(success=True, time_ms=_elapsed_ms(start), url=url, method="GET")
602
+
603
+
604
+ # ---------------------------------------------------------------------------
605
+ # Objects — IAM
606
+ # ---------------------------------------------------------------------------
607
+
608
+ @router.get("/buckets/{bucket}/objects/{object:path}/iam", response_model=GCSGenericResponse,
609
+ summary="Get object IAM policy")
610
+ async def get_object_iam(
611
+ bucket: str,
612
+ object: str,
613
+ service: GCSService = Depends(get_gcs_service),
614
+ x_credentials: Optional[str] = Header(None, alias="X-Goog-Credentials"),
615
+ x_credentials_url: Optional[str] = Header(None, alias="X-Goog-Credentials-Url"),
616
+ ):
617
+ start = time.perf_counter()
618
+ creds = await _resolve(service, payload=x_credentials, url=x_credentials_url)
619
+ try:
620
+ result = await service.get_object_iam(creds, bucket, object)
621
+ return GCSGenericResponse(success=True, time_ms=_elapsed_ms(start), data=result.get("data"))
622
+ except GCSError as exc:
623
+ return _error_response(GCSGenericResponse, start, exc)
624
+
625
+
626
+ @router.put("/buckets/{bucket}/objects/{object:path}/iam", response_model=GCSGenericResponse,
627
+ summary="Set object IAM policy")
628
+ async def set_object_iam(
629
+ bucket: str,
630
+ object: str,
631
+ body: GCSBucketIAMRequest,
632
+ service: GCSService = Depends(get_gcs_service),
633
+ ):
634
+ start = time.perf_counter()
635
+ creds = await _resolve(service, **_creds_fields(body))
636
+ try:
637
+ result = await service.set_object_iam(creds, bucket, object, body.policy)
638
+ return GCSGenericResponse(success=True, time_ms=_elapsed_ms(start), data=result.get("data"))
639
+ except GCSError as exc:
640
+ return _error_response(GCSGenericResponse, start, exc)
641
+
642
+
643
+ # ---------------------------------------------------------------------------
644
+ # Objects — ACLs
645
+ # ---------------------------------------------------------------------------
646
+
647
+ @router.get("/buckets/{bucket}/objects/{object:path}/acl", response_model=GCSGenericResponse,
648
+ summary="List object ACLs")
649
+ async def list_object_acl(
650
+ bucket: str,
651
+ object: str,
652
+ service: GCSService = Depends(get_gcs_service),
653
+ x_credentials: Optional[str] = Header(None, alias="X-Goog-Credentials"),
654
+ x_credentials_url: Optional[str] = Header(None, alias="X-Goog-Credentials-Url"),
655
+ ):
656
+ start = time.perf_counter()
657
+ creds = await _resolve(service, payload=x_credentials, url=x_credentials_url)
658
+ try:
659
+ result = await service.list_object_acl(creds, bucket, object)
660
+ return GCSGenericResponse(success=True, time_ms=_elapsed_ms(start), data=result.get("data"))
661
+ except GCSError as exc:
662
+ return _error_response(GCSGenericResponse, start, exc)
663
+
664
+
665
+ @router.post("/buckets/{bucket}/objects/{object:path}/acl", response_model=GCSGenericResponse,
666
+ summary="Add an object ACL entry")
667
+ async def insert_object_acl(
668
+ bucket: str,
669
+ object: str,
670
+ body: GCSACLRequest,
671
+ service: GCSService = Depends(get_gcs_service),
672
+ ):
673
+ start = time.perf_counter()
674
+ creds = await _resolve(service, **_creds_fields(body))
675
+ acl = {"entity": body.entity, "role": body.role}
676
+ try:
677
+ result = await service.insert_object_acl(creds, bucket, object, acl)
678
+ return GCSGenericResponse(success=True, time_ms=_elapsed_ms(start), data=result.get("data"))
679
+ except GCSError as exc:
680
+ return _error_response(GCSGenericResponse, start, exc)
681
+
682
+
683
+ @router.get("/buckets/{bucket}/objects/{object:path}/acl/{entity}", response_model=GCSGenericResponse,
684
+ summary="Get an object ACL entry")
685
+ async def get_object_acl(
686
+ bucket: str,
687
+ object: str,
688
+ entity: str,
689
+ service: GCSService = Depends(get_gcs_service),
690
+ x_credentials: Optional[str] = Header(None, alias="X-Goog-Credentials"),
691
+ x_credentials_url: Optional[str] = Header(None, alias="X-Goog-Credentials-Url"),
692
+ ):
693
+ start = time.perf_counter()
694
+ creds = await _resolve(service, payload=x_credentials, url=x_credentials_url)
695
+ try:
696
+ result = await service.get_object_acl(creds, bucket, object, entity)
697
+ return GCSGenericResponse(success=True, time_ms=_elapsed_ms(start), data=result.get("data"))
698
+ except GCSError as exc:
699
+ return _error_response(GCSGenericResponse, start, exc)
700
+
701
+
702
+ @router.patch("/buckets/{bucket}/objects/{object:path}/acl/{entity}", response_model=GCSGenericResponse,
703
+ summary="Patch an object ACL entry")
704
+ async def patch_object_acl(
705
+ bucket: str,
706
+ object: str,
707
+ entity: str,
708
+ body: GCSACLRequest,
709
+ service: GCSService = Depends(get_gcs_service),
710
+ ):
711
+ start = time.perf_counter()
712
+ creds = await _resolve(service, **_creds_fields(body))
713
+ acl = {"entity": body.entity, "role": body.role}
714
+ try:
715
+ result = await service.patch_object_acl(creds, bucket, object, entity, acl)
716
+ return GCSGenericResponse(success=True, time_ms=_elapsed_ms(start), data=result.get("data"))
717
+ except GCSError as exc:
718
+ return _error_response(GCSGenericResponse, start, exc)
719
+
720
+
721
+ @router.put("/buckets/{bucket}/objects/{object:path}/acl/{entity}", response_model=GCSGenericResponse,
722
+ summary="Update an object ACL entry")
723
+ async def update_object_acl(
724
+ bucket: str,
725
+ object: str,
726
+ entity: str,
727
+ body: GCSACLRequest,
728
+ service: GCSService = Depends(get_gcs_service),
729
+ ):
730
+ start = time.perf_counter()
731
+ creds = await _resolve(service, **_creds_fields(body))
732
+ acl = {"entity": body.entity, "role": body.role}
733
+ try:
734
+ result = await service.update_object_acl(creds, bucket, object, entity, acl)
735
+ return GCSGenericResponse(success=True, time_ms=_elapsed_ms(start), data=result.get("data"))
736
+ except GCSError as exc:
737
+ return _error_response(GCSGenericResponse, start, exc)
738
+
739
+
740
+ @router.delete("/buckets/{bucket}/objects/{object:path}/acl/{entity}", response_model=GCSGenericResponse,
741
+ summary="Delete an object ACL entry")
742
+ async def delete_object_acl(
743
+ bucket: str,
744
+ object: str,
745
+ entity: str,
746
+ body: Optional[Dict[str, Any]] = None,
747
+ service: GCSService = Depends(get_gcs_service),
748
+ ):
749
+ start = time.perf_counter()
750
+ payload = (body or {}).get("credentials_json") or (body or {}).get("credentials")
751
+ url = (body or {}).get("credentials_url")
752
+ creds = await _resolve(service, payload=payload, url=url)
753
+ try:
754
+ await service.delete_object_acl(creds, bucket, object, entity)
755
+ return GCSGenericResponse(success=True, time_ms=_elapsed_ms(start), data={"deleted": entity})
756
+ except GCSError as exc:
757
+ return _error_response(GCSGenericResponse, start, exc)
758
+
759
+
760
+ @router.get("/buckets/{bucket}/objects/{object:path}", response_model=GCSGenericResponse,
761
+ summary="Get object metadata")
762
+ async def get_object(
763
+ bucket: str,
764
+ object: str,
765
+ generation: Optional[int] = Query(None, description="Specific object generation"),
766
+ service: GCSService = Depends(get_gcs_service),
767
+ x_credentials: Optional[str] = Header(None, alias="X-Goog-Credentials"),
768
+ x_credentials_url: Optional[str] = Header(None, alias="X-Goog-Credentials-Url"),
769
+ ):
770
+ start = time.perf_counter()
771
+ creds = await _resolve(service, payload=x_credentials, url=x_credentials_url)
772
+ try:
773
+ result = await service.get_object(creds, bucket, object, generation=generation)
774
+ return GCSGenericResponse(success=True, time_ms=_elapsed_ms(start), data=result.get("data"))
775
+ except GCSError as exc:
776
+ return _error_response(GCSGenericResponse, start, exc)
777
+
778
+
779
+ @router.patch("/buckets/{bucket}/objects/{object:path}", response_model=GCSGenericResponse,
780
+ summary="Patch object metadata (partial update)")
781
+ async def patch_object(
782
+ bucket: str,
783
+ object: str,
784
+ body: GCSObjectUpdateRequest,
785
+ service: GCSService = Depends(get_gcs_service),
786
+ ):
787
+ start = time.perf_counter()
788
+ creds = await _resolve(service, **_creds_fields(body))
789
+ try:
790
+ result = await service.patch_object(creds, bucket, object, body.object,
791
+ if_generation_match=body.if_generation_match)
792
+ return GCSGenericResponse(success=True, time_ms=_elapsed_ms(start), data=result.get("data"))
793
+ except GCSError as exc:
794
+ return _error_response(GCSGenericResponse, start, exc)
795
+
796
+
797
+ @router.put("/buckets/{bucket}/objects/{object:path}", response_model=GCSGenericResponse,
798
+ summary="Replace object metadata (full update)")
799
+ async def update_object(
800
+ bucket: str,
801
+ object: str,
802
+ body: GCSObjectUpdateRequest,
803
+ service: GCSService = Depends(get_gcs_service),
804
+ ):
805
+ start = time.perf_counter()
806
+ creds = await _resolve(service, **_creds_fields(body))
807
+ try:
808
+ result = await service.update_object(creds, bucket, object, body.object,
809
+ if_generation_match=body.if_generation_match)
810
+ return GCSGenericResponse(success=True, time_ms=_elapsed_ms(start), data=result.get("data"))
811
+ except GCSError as exc:
812
+ return _error_response(GCSGenericResponse, start, exc)
813
+
814
+
815
+ @router.delete("/buckets/{bucket}/objects/{object:path}", response_model=GCSGenericResponse,
816
+ summary="Delete an object")
817
+ async def delete_object(
818
+ bucket: str,
819
+ object: str,
820
+ body: Optional[Dict[str, Any]] = None,
821
+ service: GCSService = Depends(get_gcs_service),
822
+ ):
823
+ start = time.perf_counter()
824
+ payload = (body or {}).get("credentials_json") or (body or {}).get("credentials")
825
+ url = (body or {}).get("credentials_url")
826
+ creds = await _resolve(service, payload=payload, url=url)
827
+ generation = (body or {}).get("generation")
828
+ if_generation_match = (body or {}).get("if_generation_match")
829
+ try:
830
+ await service.delete_object(creds, bucket, object,
831
+ generation=generation, if_generation_match=if_generation_match)
832
+ return GCSGenericResponse(success=True, time_ms=_elapsed_ms(start), data={"deleted": object})
833
+ except GCSError as exc:
834
+ return _error_response(GCSGenericResponse, start, exc)
835
+
836
+
837
+ # ---------------------------------------------------------------------------
838
+ # Objects — copy / move / rewrite / compose
839
+ # ---------------------------------------------------------------------------
840
+
841
+ @router.post("/buckets/{bucket}/objects/{object:path}/copy", response_model=GCSGenericResponse,
842
+ summary="Copy an object to a destination")
843
+ async def copy_object(
844
+ bucket: str,
845
+ object: str,
846
+ body: GCSObjectTransferRequest,
847
+ service: GCSService = Depends(get_gcs_service),
848
+ ):
849
+ start = time.perf_counter()
850
+ creds = await _resolve(service, **_creds_fields(body))
851
+ try:
852
+ result = await service.copy_object(
853
+ creds, bucket, object, body.destination_bucket, body.destination_name,
854
+ source_generation=body.source_generation, body=body.object,
855
+ )
856
+ return GCSGenericResponse(success=True, time_ms=_elapsed_ms(start), data=result.get("data"))
857
+ except GCSError as exc:
858
+ return _error_response(GCSGenericResponse, start, exc)
859
+
860
+
861
+ @router.post("/buckets/{bucket}/objects/{object:path}/move", response_model=GCSGenericResponse,
862
+ summary="Move an object to a destination")
863
+ async def move_object(
864
+ bucket: str,
865
+ object: str,
866
+ body: GCSObjectTransferRequest,
867
+ service: GCSService = Depends(get_gcs_service),
868
+ ):
869
+ start = time.perf_counter()
870
+ creds = await _resolve(service, **_creds_fields(body))
871
+ try:
872
+ result = await service.move_object(
873
+ creds, bucket, object, body.destination_bucket, body.destination_name,
874
+ source_generation=body.source_generation, if_generation_match=body.if_generation_match,
875
+ body=body.object,
876
+ )
877
+ return GCSGenericResponse(success=True, time_ms=_elapsed_ms(start), data=result.get("data"))
878
+ except GCSError as exc:
879
+ return _error_response(GCSGenericResponse, start, exc)
880
+
881
+
882
+ @router.post("/buckets/{bucket}/objects/{object:path}/rewrite", response_model=GCSGenericResponse,
883
+ summary="Rewrite (async copy) an object to a destination")
884
+ async def rewrite_object(
885
+ bucket: str,
886
+ object: str,
887
+ body: GCSObjectTransferRequest,
888
+ service: GCSService = Depends(get_gcs_service),
889
+ ):
890
+ start = time.perf_counter()
891
+ creds = await _resolve(service, **_creds_fields(body))
892
+ try:
893
+ result = await service.rewrite_object(
894
+ creds, bucket, object, body.destination_bucket, body.destination_name,
895
+ rewrite_token=body.rewrite_token,
896
+ max_bytes_rewritten_per_call=body.max_bytes_rewritten_per_call,
897
+ body=body.object,
898
+ )
899
+ return GCSGenericResponse(success=True, time_ms=_elapsed_ms(start), data=result.get("data"))
900
+ except GCSError as exc:
901
+ return _error_response(GCSGenericResponse, start, exc)
902
+
903
+
904
+ @router.post("/buckets/{bucket}/objects/{object:path}/hold", response_model=GCSGenericResponse,
905
+ summary="Set an event-based hold on an object")
906
+ async def set_object_hold(
907
+ bucket: str,
908
+ object: str,
909
+ body: GCSBaseCredentialsRequest,
910
+ service: GCSService = Depends(get_gcs_service),
911
+ ):
912
+ start = time.perf_counter()
913
+ creds = await _resolve(service, **_creds_fields(body))
914
+ try:
915
+ result = await service.set_object_hold(creds, bucket, object)
916
+ return GCSGenericResponse(success=True, time_ms=_elapsed_ms(start), data=result.get("data"))
917
+ except GCSError as exc:
918
+ return _error_response(GCSGenericResponse, start, exc)
919
+
920
+
921
+ @router.post("/buckets/{bucket}/objects/{object:path}/releaseHold", response_model=GCSGenericResponse,
922
+ summary="Release an event-based hold on an object")
923
+ async def release_object_hold(
924
+ bucket: str,
925
+ object: str,
926
+ body: GCSBaseCredentialsRequest,
927
+ service: GCSService = Depends(get_gcs_service),
928
+ ):
929
+ start = time.perf_counter()
930
+ creds = await _resolve(service, **_creds_fields(body))
931
+ try:
932
+ result = await service.release_object_hold(creds, bucket, object)
933
+ return GCSGenericResponse(success=True, time_ms=_elapsed_ms(start), data=result.get("data"))
934
+ except GCSError as exc:
935
+ return _error_response(GCSGenericResponse, start, exc)
936
+
937
+
938
+ @router.post("/buckets/{bucket}/objects/{object:path}/lockRetentionPolicy", response_model=GCSGenericResponse,
939
+ summary="Lock a retention policy on an object (permanent)")
940
+ async def lock_object_retention(
941
+ bucket: str,
942
+ object: str,
943
+ body: GCSBucketRestoreRequest,
944
+ service: GCSService = Depends(get_gcs_service),
945
+ ):
946
+ start = time.perf_counter()
947
+ creds = await _resolve(service, **_creds_fields(body))
948
+ try:
949
+ result = await service.lock_object_retention(
950
+ creds, bucket, object, if_metageneration_match=body.if_metageneration_match
951
+ )
952
+ return GCSGenericResponse(success=True, time_ms=_elapsed_ms(start), data=result.get("data"))
953
+ except GCSError as exc:
954
+ return _error_response(GCSGenericResponse, start, exc)
955
+
956
+
957
+ @router.post("/buckets/{bucket}/objects/{object:path}/compose", response_model=GCSGenericResponse,
958
+ summary="Compose multiple source objects into this destination object")
959
+ async def compose_object(
960
+ bucket: str,
961
+ object: str,
962
+ body: GCSComposeRequest,
963
+ service: GCSService = Depends(get_gcs_service),
964
+ ):
965
+ start = time.perf_counter()
966
+ creds = await _resolve(service, **_creds_fields(body))
967
+ try:
968
+ result = await service.compose_object(creds, bucket, object, body.source_objects, body=body.destination)
969
+ return GCSGenericResponse(success=True, time_ms=_elapsed_ms(start), data=result.get("data"))
970
+ except GCSError as exc:
971
+ return _error_response(GCSGenericResponse, start, exc)
972
+
973
+
974
+ # ---------------------------------------------------------------------------
975
+ # Public & signed URLs
976
+ # ---------------------------------------------------------------------------
977
+
978
+ @router.post("/buckets/{bucket}/objects/{object:path}/signed-url", response_model=GCSURLResponse,
979
+ summary="Generate a V4 signed download URL for an object")
980
+ async def signed_download_url(
981
+ bucket: str,
982
+ object: str,
983
+ body: GCSURLRequest,
984
+ service: GCSService = Depends(get_gcs_service),
985
+ ):
986
+ start = time.perf_counter()
987
+ creds = await _resolve(service, **_creds_fields(body))
988
+ try:
989
+ url, expires = await service.download_signed_url(
990
+ creds, bucket, object,
991
+ expires_in_seconds=body.expires_in_seconds,
992
+ response_content_type=body.response_content_type,
993
+ response_disposition=body.response_disposition,
994
+ )
995
+ return GCSURLResponse(
996
+ success=True, time_ms=_elapsed_ms(start), url=url,
997
+ method="GET", expires_in_seconds=expires,
998
+ )
999
+ except GCSError as exc:
1000
+ return _error_response(GCSURLResponse, start, exc)
1001
+
1002
+
1003
+ @router.post("/buckets/{bucket}/objects/{object:path}/signed-upload-url", response_model=GCSURLResponse,
1004
+ summary="Generate a V4 signed upload URL for an object (PUT)")
1005
+ async def signed_upload_url(
1006
+ bucket: str,
1007
+ object: str,
1008
+ body: GCSURLRequest,
1009
+ service: GCSService = Depends(get_gcs_service),
1010
+ ):
1011
+ start = time.perf_counter()
1012
+ creds = await _resolve(service, **_creds_fields(body))
1013
+ try:
1014
+ url, expires = await service.upload_signed_url(
1015
+ creds, bucket, object,
1016
+ expires_in_seconds=body.expires_in_seconds,
1017
+ content_type=body.content_type,
1018
+ )
1019
+ return GCSURLResponse(
1020
+ success=True, time_ms=_elapsed_ms(start), url=url,
1021
+ method="PUT", expires_in_seconds=expires,
1022
+ )
1023
+ except GCSError as exc:
1024
+ return _error_response(GCSURLResponse, start, exc)
app/api/v1/router.py CHANGED
@@ -11,6 +11,7 @@ from app.api.v1 import (
11
  csv_analysis,
12
  database,
13
  embeddings,
 
14
  google_maps,
15
  google_oauth,
16
  json_extract,
@@ -55,6 +56,7 @@ api_v1_router.include_router(webhook_socket.router, tags=["Webhook / Socket"])
55
  api_v1_router.include_router(csv_analysis.router, tags=["CSV Analysis"])
56
  api_v1_router.include_router(google_maps.router, tags=["Google Maps"])
57
  api_v1_router.include_router(google_oauth.router, tags=["Google OAuth"])
 
58
  api_v1_router.include_router(json_extract.router, tags=["JSON Extractor"])
59
  api_v1_router.include_router(keys_extract.router, prefix="/json", tags=["Keys Extractor"])
60
  api_v1_router.include_router(qr_decoder.router, tags=["QR Decoder"])
 
11
  csv_analysis,
12
  database,
13
  embeddings,
14
+ gcs,
15
  google_maps,
16
  google_oauth,
17
  json_extract,
 
56
  api_v1_router.include_router(csv_analysis.router, tags=["CSV Analysis"])
57
  api_v1_router.include_router(google_maps.router, tags=["Google Maps"])
58
  api_v1_router.include_router(google_oauth.router, tags=["Google OAuth"])
59
+ api_v1_router.include_router(gcs.router, tags=["Google Cloud Storage"])
60
  api_v1_router.include_router(json_extract.router, tags=["JSON Extractor"])
61
  api_v1_router.include_router(keys_extract.router, prefix="/json", tags=["Keys Extractor"])
62
  api_v1_router.include_router(qr_decoder.router, tags=["QR Decoder"])
app/config.py CHANGED
@@ -102,6 +102,17 @@ class Settings(BaseSettings):
102
  google_maps_timeout: int = 15
103
  google_maps_max_retries: int = 2
104
 
 
 
 
 
 
 
 
 
 
 
 
105
  # Google OAuth 2.0 (OpenID Connect) Sign-In settings
106
  google_oauth_auth_url: str = "https://accounts.google.com/o/oauth2/v2/auth"
107
  google_oauth_token_url: str = "https://oauth2.googleapis.com/token"
 
102
  google_maps_timeout: int = 15
103
  google_maps_max_retries: int = 2
104
 
105
+ # Google Cloud Storage (GCS) settings
106
+ gcs_bucket_name: str = Field(default="", alias="GCS_BUCKET_NAME")
107
+ gcs_service_account_key_path: str = Field(default="", alias="GCS_SERVICE_ACCOUNT_KEY_PATH")
108
+ gcs_api_base_url: str = "https://storage.googleapis.com/storage/v1"
109
+ gcs_upload_base_url: str = "https://storage.googleapis.com/upload/storage/v1"
110
+ gcs_token_uri: str = "https://oauth2.googleapis.com/token"
111
+ gcs_timeout: float = 30.0
112
+ gcs_max_retries: int = 2
113
+ gcs_default_expires_seconds: int = 3600
114
+ gcs_max_expires_seconds: int = 604800
115
+
116
  # Google OAuth 2.0 (OpenID Connect) Sign-In settings
117
  google_oauth_auth_url: str = "https://accounts.google.com/o/oauth2/v2/auth"
118
  google_oauth_token_url: str = "https://oauth2.googleapis.com/token"
app/models/schemas.py CHANGED
@@ -1187,3 +1187,265 @@ class GoogleStaticMapResponse(BaseModel):
1187
  format: str = Field("png", description="Image format")
1188
  warning: Optional[str] = Field(None, description="Warning returned by the Maps Static API, if any")
1189
  error: Optional[str] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1187
  format: str = Field("png", description="Image format")
1188
  warning: Optional[str] = Field(None, description="Warning returned by the Maps Static API, if any")
1189
  error: Optional[str] = None
1190
+
1191
+
1192
+ # ---------------------------------------------------------------------------
1193
+ # Google Cloud Storage (GCS) — Service Account JSON API
1194
+ # ---------------------------------------------------------------------------
1195
+
1196
+ GCS_BUCKET_NAME_REGEX = r"^[a-z0-9][a-z0-9._-]{1,61}[a-z0-9]$"
1197
+
1198
+
1199
+ class GCSBaseCredentialsRequest(BaseModel):
1200
+ """Reusable service-account credential fields accepted by every GCS endpoint.
1201
+
1202
+ Credentials may be supplied inline as a JSON object (``credentials``), as a
1203
+ raw JSON string (``credentials_json``), or via a URL (``credentials_url``).
1204
+ When none are supplied, the server falls back to the environment-configured
1205
+ service account (``GCS_SERVICE_ACCOUNT_KEY_PATH``).
1206
+ """
1207
+
1208
+ credentials: Optional[Dict[str, Any]] = Field(
1209
+ None, description="Service account key as an inline JSON object"
1210
+ )
1211
+ credentials_json: Optional[str] = Field(
1212
+ None, description="Service account key as a raw JSON string"
1213
+ )
1214
+ credentials_url: Optional[str] = Field(
1215
+ None, description="Publicly reachable URL that returns the service account JSON"
1216
+ )
1217
+
1218
+
1219
+ class GCSBucketRequest(GCSBaseCredentialsRequest):
1220
+ """Create or fully replace a bucket resource."""
1221
+
1222
+ name: str = Field(..., pattern=GCS_BUCKET_NAME_REGEX, description="Globally unique bucket name")
1223
+ project_id: Optional[str] = Field(
1224
+ None, description="GCP project id used for the operation (defaults to the service account project)"
1225
+ )
1226
+ location: Optional[str] = Field(
1227
+ None, pattern=r"^[A-Za-z0-9._-]+$", description="Bucket location (e.g. 'US-EAST1')"
1228
+ )
1229
+ storage_class: Optional[Literal["STANDARD", "NEARLINE", "COLDLINE", "ARCHIVE", "STANDARD_IA", "MULTI_REGIONAL", "REGIONAL", "DURABLE_REDUCED_AVAILABILITY"]] = Field(
1230
+ None, description="Storage class for the bucket"
1231
+ )
1232
+ bucket: Optional[Dict[str, Any]] = Field(
1233
+ None, description="Full GCS Bucket resource; merged over the convenience fields above"
1234
+ )
1235
+
1236
+
1237
+ class GCSBucketUpdateRequest(GCSBaseCredentialsRequest):
1238
+ """Patch (partial) or update (full) a bucket's metadata."""
1239
+
1240
+ bucket: Dict[str, Any] = Field(..., description="GCS Bucket resource fields to apply")
1241
+
1242
+
1243
+ class GCSBucketDeleteRequest(GCSBaseCredentialsRequest):
1244
+ """Delete a bucket, optionally guarded by a metageneration match."""
1245
+
1246
+ if_metageneration_match: Optional[int] = Field(
1247
+ None, description="Only delete if the bucket metageneration matches"
1248
+ )
1249
+
1250
+
1251
+ class GCSBucketIAMRequest(GCSBaseCredentialsRequest):
1252
+ policy: Dict[str, Any] = Field(..., description="GCS IAM Policy resource")
1253
+
1254
+
1255
+ class GCSBucketPermissionsRequest(GCSBaseCredentialsRequest):
1256
+ permissions: List[str] = Field(
1257
+ ..., min_length=1, description="Permissions to test (e.g. 'storage.buckets.get', 'storage.objects.list')"
1258
+ )
1259
+
1260
+
1261
+ class GCSACLRequest(GCSBaseCredentialsRequest):
1262
+ """Insert or update an ACL entry."""
1263
+
1264
+ entity: str = Field(
1265
+ ..., min_length=1, max_length=1024,
1266
+ description="ACL entity (e.g. 'allUsers', 'user-email@example.com', 'project-owners-1234')",
1267
+ )
1268
+ role: Literal["OWNER", "READER", "WRITER"] = Field(..., description="ACL role")
1269
+
1270
+ @field_validator("entity")
1271
+ @classmethod
1272
+ def _clean_entity(cls, v: str) -> str:
1273
+ cleaned = v.strip()
1274
+ if not cleaned:
1275
+ raise ValueError("entity must not be empty or whitespace")
1276
+ return cleaned
1277
+
1278
+
1279
+ class GCSObjectUpdateRequest(GCSBaseCredentialsRequest):
1280
+ """Patch or update object metadata."""
1281
+
1282
+ object: Dict[str, Any] = Field(..., description="GCS Object resource fields to apply")
1283
+ if_generation_match: Optional[int] = Field(
1284
+ None, description="Only apply if the object generation matches"
1285
+ )
1286
+
1287
+
1288
+ class GCSObjectTransferRequest(GCSBaseCredentialsRequest):
1289
+ """Copy, move, or rewrite an object to a destination."""
1290
+
1291
+ destination_bucket: str = Field(..., pattern=GCS_BUCKET_NAME_REGEX, description="Destination bucket")
1292
+ destination_name: str = Field(..., min_length=1, description="Destination object name")
1293
+ source_generation: Optional[int] = Field(
1294
+ None, description="Generation of the source object to operate on"
1295
+ )
1296
+ if_generation_match: Optional[int] = Field(
1297
+ None, description="Generation condition for the destination object"
1298
+ )
1299
+ rewrite_token: Optional[str] = Field(
1300
+ None, description="Rewrite token from a previous (paged) rewrite response"
1301
+ )
1302
+ max_bytes_rewritten_per_call: Optional[int] = Field(
1303
+ None, ge=1, description="Maximum bytes to rewrite per call for large object rewrites"
1304
+ )
1305
+ object: Optional[Dict[str, Any]] = Field(
1306
+ None, description="Destination object metadata to apply during the transfer"
1307
+ )
1308
+
1309
+
1310
+ class GCSComposeRequest(GCSBaseCredentialsRequest):
1311
+ """Compose multiple source objects into a single destination object."""
1312
+
1313
+ source_objects: List[str] = Field(..., min_length=1, description="Names of source objects to compose")
1314
+ destination: Optional[Dict[str, Any]] = Field(
1315
+ None, description="Destination object metadata"
1316
+ )
1317
+
1318
+ @field_validator("source_objects")
1319
+ @classmethod
1320
+ def _clean_sources(cls, v: List[str]) -> List[str]:
1321
+ cleaned = [name.strip() for name in v if name and name.strip()]
1322
+ if not cleaned:
1323
+ raise ValueError("source_objects must contain at least one non-empty object name")
1324
+ if any(name.startswith("/") or len(name) > 1024 for name in cleaned):
1325
+ raise ValueError("source object names must be 1-1024 characters and not start with '/'")
1326
+ return cleaned
1327
+
1328
+
1329
+ class GCSObjectDownloadRequest(GCSBaseCredentialsRequest):
1330
+ """Request an object's bytes as base64 (JSON-friendly alternative to the raw GET)."""
1331
+
1332
+ generation: Optional[int] = Field(None, description="Specific object generation to download")
1333
+
1334
+
1335
+ class GCSObjectDeleteRequest(GCSBaseCredentialsRequest):
1336
+ """Delete an object, optionally pinned to a generation or guarded by a match."""
1337
+
1338
+ generation: Optional[int] = Field(None, description="Specific object generation to delete")
1339
+ if_generation_match: Optional[int] = Field(
1340
+ None, description="Only delete if the object generation matches"
1341
+ )
1342
+
1343
+
1344
+ class GCSResumableUploadRequest(GCSBaseCredentialsRequest):
1345
+ """Start a resumable upload session for an object."""
1346
+
1347
+ name: str = Field(
1348
+ ..., min_length=1, max_length=1024, description="Destination object name"
1349
+ )
1350
+ content_type: Optional[str] = Field(
1351
+ None, description="Content-Type of the object (defaults to application/octet-stream)"
1352
+ )
1353
+ object_metadata: Optional[Dict[str, Any]] = Field(
1354
+ None, description="Object metadata to apply to the created object"
1355
+ )
1356
+
1357
+ @field_validator("name")
1358
+ @classmethod
1359
+ def _validate_object_name(cls, v: str) -> str:
1360
+ cleaned = v.strip()
1361
+ if cleaned.startswith("/"):
1362
+ raise ValueError("object name must not start with '/'")
1363
+ return cleaned
1364
+
1365
+
1366
+ class GCSBucketRestoreRequest(GCSBaseCredentialsRequest):
1367
+ """Restore a soft-deleted bucket."""
1368
+
1369
+ if_metageneration_match: Optional[int] = Field(
1370
+ None, description="Only restore if the bucket metageneration matches"
1371
+ )
1372
+
1373
+
1374
+ class GCSWatchRequest(GCSBaseCredentialsRequest):
1375
+ """Register an object-change notification channel for a bucket."""
1376
+
1377
+ channel: Dict[str, Any] = Field(..., description="GCS Channel resource for object change notifications")
1378
+
1379
+
1380
+ class GCSURLRequest(GCSBaseCredentialsRequest):
1381
+ """Generate a public or signed URL for an object."""
1382
+
1383
+ expires_in_seconds: Optional[int] = Field(
1384
+ None, ge=1, le=604800, description="Signed URL lifetime in seconds (max 604800)"
1385
+ )
1386
+ content_type: Optional[str] = Field(
1387
+ None, description="Content-Type to sign for PUT (upload) signed URLs"
1388
+ )
1389
+ response_content_type: Optional[str] = Field(
1390
+ None, description="For downloads: override the served Content-Type"
1391
+ )
1392
+ response_disposition: Optional[str] = Field(
1393
+ None, description="For downloads: set the Content-Disposition (e.g. 'attachment; filename=...')"
1394
+ )
1395
+
1396
+ @field_validator("content_type", "response_content_type")
1397
+ @classmethod
1398
+ def _validate_media_type(cls, v: Optional[str]) -> Optional[str]:
1399
+ if v is not None and not re.fullmatch(r"[\w./+-]{1,255}", v):
1400
+ raise ValueError("content_type must be a valid media type (e.g. 'application/pdf')")
1401
+ return v
1402
+
1403
+ @field_validator("response_disposition")
1404
+ @classmethod
1405
+ def _validate_disposition(cls, v: Optional[str]) -> Optional[str]:
1406
+ if v is not None and len(v) > 500:
1407
+ raise ValueError("response_disposition must be at most 500 characters")
1408
+ return v
1409
+
1410
+
1411
+ class GCSGenericResponse(BaseModel):
1412
+ """Envelope for most GCS operations."""
1413
+
1414
+ success: bool
1415
+ time_ms: float = Field(0.0, description="Server-side processing time in milliseconds")
1416
+ data: Optional[Dict[str, Any]] = Field(None, description="GCS API response payload")
1417
+ error: Optional[str] = None
1418
+
1419
+
1420
+ class GCSListResponse(BaseModel):
1421
+ """Envelope for paginated list operations."""
1422
+
1423
+ success: bool
1424
+ time_ms: float = 0.0
1425
+ count: int = 0
1426
+ next_page_token: Optional[str] = None
1427
+ items: List[Dict[str, Any]] = Field(default_factory=list, description="Listed items")
1428
+ prefixes: List[str] = Field(default_factory=list, description="Common prefixes when a delimiter was used")
1429
+ error: Optional[str] = None
1430
+
1431
+
1432
+ class GCSDownloadResponse(BaseModel):
1433
+ """Envelope for object downloads returned as base64."""
1434
+
1435
+ success: bool
1436
+ time_ms: float = 0.0
1437
+ content_base64: Optional[str] = Field(None, description="Base64-encoded object bytes")
1438
+ content_type: Optional[str] = Field(None, description="Content-Type of the object")
1439
+ size_bytes: int = 0
1440
+ error: Optional[str] = None
1441
+
1442
+
1443
+ class GCSURLResponse(BaseModel):
1444
+ """Envelope for public / signed URL generation."""
1445
+
1446
+ success: bool
1447
+ time_ms: float = 0.0
1448
+ url: Optional[str] = Field(None, description="Generated URL")
1449
+ method: Optional[str] = Field(None, description="HTTP method the signed URL authorizes")
1450
+ expires_in_seconds: Optional[int] = Field(None, description="Signed URL lifetime in seconds")
1451
+ error: Optional[str] = None
app/services/gcs_service.py ADDED
@@ -0,0 +1,1077 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import base64
5
+ import hashlib
6
+ import json
7
+ import re
8
+ import time
9
+ from datetime import datetime, timezone
10
+ from typing import Any, Dict, List, Optional, Tuple
11
+ from urllib.parse import quote, urlencode
12
+
13
+ import httpx
14
+ from pydantic import BaseModel, ValidationError
15
+
16
+ from app.config import get_settings
17
+ from app.core.logger import get_logger
18
+
19
+ _logger = get_logger(__name__)
20
+ _settings = get_settings()
21
+
22
+ # Scopes needed to perform full object/bucket management via the JSON API.
23
+ _GCS_SCOPES = (
24
+ "https://www.googleapis.com/auth/devstorage.full_control "
25
+ "https://www.googleapis.com/auth/cloud-platform"
26
+ )
27
+
28
+ # Transient HTTP status codes that are safe to retry against Google.
29
+ _RETRYABLE_STATUS = frozenset({429, 500, 502, 503, 504})
30
+
31
+ # Cloud Storage bucket naming rules (3-63 chars, lowercase letters/digits/_/.-).
32
+ BUCKET_NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9._-]{1,61}[a-z0-9]$")
33
+
34
+ _SIGNING_ALGORITHM = "GOOG4-RSA-SHA256"
35
+ _SIGNING_REGION = "auto"
36
+ _SIGNING_SERVICE = "storage"
37
+
38
+
39
+ class GCSCredentialsError(Exception):
40
+ """Raised when service-account credentials are missing or malformed."""
41
+
42
+ def __init__(self, message: str) -> None:
43
+ super().__init__(message)
44
+ self.message = message
45
+
46
+
47
+ class GCSError(Exception):
48
+ """Raised for upstream Cloud Storage failures mapped to client-facing errors."""
49
+
50
+ def __init__(self, message: str, status_code: int = 400) -> None:
51
+ super().__init__(message)
52
+ self.message = message
53
+ self.status_code = status_code
54
+
55
+
56
+ class GCSCredentials(BaseModel):
57
+ """Validated Google service-account JSON key material."""
58
+
59
+ type: str = "service_account"
60
+ project_id: str = ""
61
+ private_key_id: str = ""
62
+ private_key: str
63
+ client_email: str
64
+ client_id: str = ""
65
+ auth_uri: str = "https://accounts.google.com/o/oauth2/auth"
66
+ token_uri: str = "https://oauth2.googleapis.com/token"
67
+ auth_provider_x509_cert_url: str = "https://www.googleapis.com/oauth2/v1/certs"
68
+ client_x509_cert_url: str = ""
69
+ universe_domain: str = "googleapis.com"
70
+
71
+ @property
72
+ def scope_key(self) -> str:
73
+ """Unique key used to share cached access tokens across requests."""
74
+ return f"{self.client_email}|{self.private_key_id}"
75
+
76
+
77
+ class _TokenCache:
78
+ """In-memory cache of short-lived Google access tokens, keyed per service account."""
79
+
80
+ def __init__(self) -> None:
81
+ self._entries: Dict[str, Dict[str, Any]] = {}
82
+ self._lock = asyncio.Lock()
83
+
84
+ def _key(self, creds: GCSCredentials) -> str:
85
+ return f"{creds.client_email}|{creds.private_key_id}"
86
+
87
+ def get(self, creds: GCSCredentials) -> Optional[str]:
88
+ entry = self._entries.get(self._key(creds))
89
+ if entry and entry["expires_at"] > time.time():
90
+ return entry["token"]
91
+ return None
92
+
93
+ async def set(self, creds: GCSCredentials, token: str, expires_in: int) -> None:
94
+ self._entries[self._key(creds)] = {
95
+ "token": token,
96
+ "expires_at": time.time() + max(expires_in - 60, 60),
97
+ }
98
+
99
+
100
+ class GCSService:
101
+ """Asynchronous, connection-pooled client for the Google Cloud Storage JSON API."""
102
+
103
+ def __init__(self) -> None:
104
+ self._client: Optional[httpx.AsyncClient] = None
105
+ self._client_lock = asyncio.Lock()
106
+ self._token_cache = _TokenCache()
107
+
108
+ # ------------------------------------------------------------------
109
+ # HTTP client management
110
+ # ------------------------------------------------------------------
111
+
112
+ async def _get_client(self) -> httpx.AsyncClient:
113
+ if self._client is None or self._client.is_closed:
114
+ async with self._client_lock:
115
+ if self._client is None or self._client.is_closed:
116
+ self._client = httpx.AsyncClient(
117
+ timeout=_settings.gcs_timeout,
118
+ follow_redirects=True,
119
+ limits=httpx.Limits(
120
+ max_connections=100,
121
+ max_keepalive_connections=20,
122
+ keepalive_expiry=30,
123
+ ),
124
+ )
125
+ return self._client
126
+
127
+ async def close(self) -> None:
128
+ async with self._client_lock:
129
+ if self._client is not None and not self._client.is_closed:
130
+ await self._client.aclose()
131
+ self._client = None
132
+
133
+ # ------------------------------------------------------------------
134
+ # Credential resolution (JSON body / JSON string / URL / file / env)
135
+ # ------------------------------------------------------------------
136
+
137
+ async def resolve_credentials(
138
+ self,
139
+ *,
140
+ payload: Optional[Any] = None,
141
+ url: Optional[str] = None,
142
+ file_bytes: Optional[bytes] = None,
143
+ file_name: Optional[str] = None,
144
+ ) -> GCSCredentials:
145
+ """Resolve a validated service-account credential from any supported source.
146
+
147
+ Resolution precedence: inline ``payload`` (dict or JSON string), then a
148
+ URL to fetch, then uploaded ``file_bytes``, then the env-configured path.
149
+ """
150
+ raw: Optional[str] = None
151
+ source: str = ""
152
+
153
+ if file_bytes is not None:
154
+ source = f"uploaded file '{file_name or 'credentials'}'"
155
+ raw = file_bytes.decode("utf-8")
156
+ elif payload is not None:
157
+ if isinstance(payload, dict):
158
+ return self._validate_credentials(payload, "request body")
159
+ if isinstance(payload, str) and payload.strip():
160
+ source = "request body"
161
+ raw = payload
162
+ else:
163
+ raise GCSCredentialsError(
164
+ "credentials must be a JSON object or a JSON string."
165
+ )
166
+ elif url:
167
+ source = f"url '{url}'"
168
+ raw = await self._fetch_credentials_url(url)
169
+ elif _settings.gcs_service_account_key_path:
170
+ source = f"file '{_settings.gcs_service_account_key_path}'"
171
+ try:
172
+ with open(_settings.gcs_service_account_key_path, "r", encoding="utf-8") as f:
173
+ raw = f.read()
174
+ except OSError as exc:
175
+ raise GCSCredentialsError(
176
+ f"Unable to read service account file at "
177
+ f"'{_settings.gcs_service_account_key_path}': {exc}"
178
+ )
179
+ else:
180
+ raise GCSCredentialsError(
181
+ "No GCS service account credentials provided. Pass 'credentials' "
182
+ "(JSON object) or 'credentials_json' in the body, 'credentials_url', "
183
+ "upload a credentials file, or set GCS_SERVICE_ACCOUNT_KEY_PATH."
184
+ )
185
+
186
+ try:
187
+ data = json.loads(raw)
188
+ except (json.JSONDecodeError, TypeError) as exc:
189
+ raise GCSCredentialsError(
190
+ f"Service account data from {source} is not valid JSON: {exc}"
191
+ )
192
+ return self._validate_credentials(data, source)
193
+
194
+ @staticmethod
195
+ def _validate_credentials(data: Any, source: str) -> GCSCredentials:
196
+ if not isinstance(data, dict):
197
+ raise GCSCredentialsError(f"Service account data from {source} must be a JSON object.")
198
+ try:
199
+ return GCSCredentials(**data)
200
+ except ValidationError as exc:
201
+ raise GCSCredentialsError(
202
+ f"Service account data from {source} is missing required fields: "
203
+ f"{', '.join(e['loc'][0] for e in exc.errors())}"
204
+ )
205
+
206
+ @staticmethod
207
+ def _validate_http_url(url: str) -> None:
208
+ if not isinstance(url, str) or not url.strip():
209
+ raise GCSCredentialsError("A non-empty URL string is required.")
210
+ if len(url) > 2048:
211
+ raise GCSCredentialsError("URL must be at most 2048 characters.")
212
+ parts = url.split("://", 1)
213
+ if len(parts) != 2 or parts[0].lower() not in ("http", "https"):
214
+ raise GCSCredentialsError("URL must use the http or https scheme.")
215
+
216
+ async def _fetch_credentials_url(self, url: str) -> str:
217
+ self._validate_http_url(url)
218
+ client = await self._get_client()
219
+ try:
220
+ response = await client.get(url)
221
+ response.raise_for_status()
222
+ except httpx.HTTPError as exc:
223
+ raise GCSCredentialsError(f"Failed to fetch service account JSON from '{url}': {exc}")
224
+ return response.text
225
+
226
+ async def fetch_url_content(self, url: str) -> Tuple[bytes, str]:
227
+ """Fetch arbitrary content from a URL for use as an object body."""
228
+ self._validate_http_url(url)
229
+ client = await self._get_client()
230
+ try:
231
+ response = await client.get(url)
232
+ response.raise_for_status()
233
+ except httpx.HTTPError as exc:
234
+ raise GCSError(f"Failed to fetch content from '{url}': {exc}", status_code=400)
235
+ return response.content, response.headers.get("content-type", "application/octet-stream")
236
+
237
+ # ------------------------------------------------------------------
238
+ # Access token acquisition (service account JWT -> bearer token)
239
+ # ------------------------------------------------------------------
240
+
241
+ async def get_access_token(self, creds: GCSCredentials) -> str:
242
+ cached = self._token_cache.get(creds)
243
+ if cached:
244
+ return cached
245
+ async with self._token_cache._lock:
246
+ cached = self._token_cache.get(creds)
247
+ if cached:
248
+ return cached
249
+ token, expires_in = await self._fetch_access_token(creds)
250
+ await self._token_cache.set(creds, token, expires_in)
251
+ return token
252
+
253
+ async def _fetch_access_token(self, creds: GCSCredentials) -> Tuple[str, int]:
254
+ assertion = self._build_signed_jwt(creds)
255
+ body = urlencode({
256
+ "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
257
+ "assertion": assertion,
258
+ })
259
+ client = await self._get_client()
260
+ last_error: Optional[str] = None
261
+ for attempt in range(1 + _settings.gcs_max_retries):
262
+ try:
263
+ response = await client.request(
264
+ "POST",
265
+ creds.token_uri or _settings.gcs_token_uri,
266
+ content=body,
267
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
268
+ )
269
+ if response.status_code == 200:
270
+ data = response.json()
271
+ token: str = data.get("access_token", "")
272
+ if not token:
273
+ raise GCSCredentialsError(
274
+ "Google did not return an access token for the service account."
275
+ )
276
+ return token, int(data.get("expires_in", 3600))
277
+
278
+ last_error = self._format_token_error(response)
279
+ _logger.warning(
280
+ "GCS token exchange failed (attempt %d/%d): %s",
281
+ attempt + 1, 1 + _settings.gcs_max_retries, last_error,
282
+ )
283
+ if response.status_code in _RETRYABLE_STATUS and attempt < _settings.gcs_max_retries:
284
+ await asyncio.sleep(2.0 ** attempt)
285
+ continue
286
+ raise GCSCredentialsError(last_error)
287
+
288
+ except httpx.TimeoutException:
289
+ last_error = "Google token endpoint timed out."
290
+ except httpx.RequestError as exc:
291
+ last_error = f"Google token endpoint request failed: {exc}"
292
+ except GCSCredentialsError:
293
+ raise
294
+ except Exception as exc:
295
+ last_error = f"Unexpected error during token exchange: {exc}"
296
+
297
+ if attempt < _settings.gcs_max_retries:
298
+ await asyncio.sleep(2.0 ** attempt)
299
+
300
+ raise GCSCredentialsError(last_error or "Unknown token exchange failure.")
301
+
302
+ @staticmethod
303
+ def _format_token_error(response: httpx.Response) -> str:
304
+ try:
305
+ body = response.json()
306
+ error_desc = body.get("error_description", "") or body.get("error", "")
307
+ if error_desc:
308
+ return error_desc if isinstance(error_desc, str) else str(error_desc)
309
+ except Exception:
310
+ pass
311
+ return f"Google OAuth token exchange error (HTTP {response.status_code})."
312
+
313
+ def _build_signed_jwt(self, creds: GCSCredentials) -> str:
314
+ now = int(time.time())
315
+ header = {"alg": "RS256", "typ": "JWT"}
316
+ claims = {
317
+ "iss": creds.client_email,
318
+ "scope": _GCS_SCOPES,
319
+ "aud": creds.token_uri or _settings.gcs_token_uri,
320
+ "iat": now,
321
+ "exp": now + 3600,
322
+ }
323
+ from cryptography.hazmat.primitives import hashes
324
+ from cryptography.hazmat.primitives.asymmetric import padding
325
+ from cryptography.hazmat.primitives.serialization import load_pem_private_key
326
+
327
+ signing_input = (
328
+ base64.urlsafe_b64encode(json.dumps(header, separators=(",", ":")).encode()).rstrip(b"=")
329
+ + b"."
330
+ + base64.urlsafe_b64encode(json.dumps(claims, separators=(",", ":")).encode()).rstrip(b"=")
331
+ )
332
+ private_key = load_pem_private_key(creds.private_key.encode("utf-8"), password=None)
333
+ signature = private_key.sign(signing_input, padding.PKCS1v15(), hashes.SHA256())
334
+ return (
335
+ signing_input
336
+ + b"."
337
+ + base64.urlsafe_b64encode(signature).rstrip(b"=")
338
+ ).decode("ascii")
339
+
340
+ # ------------------------------------------------------------------
341
+ # Generic authenticated JSON API request
342
+ # ------------------------------------------------------------------
343
+
344
+ async def _request(
345
+ self,
346
+ method: str,
347
+ path: str,
348
+ creds: GCSCredentials,
349
+ *,
350
+ params: Optional[Dict[str, Any]] = None,
351
+ json_body: Optional[Any] = None,
352
+ extra_headers: Optional[Dict[str, str]] = None,
353
+ content: Optional[bytes] = None,
354
+ upload: bool = False,
355
+ ) -> Dict[str, Any]:
356
+ token = await self.get_access_token(creds)
357
+ base_url = _settings.gcs_upload_base_url if upload else _settings.gcs_api_base_url
358
+ url = f"{base_url}{path}"
359
+ headers = {"Authorization": f"Bearer {token}"}
360
+ if extra_headers:
361
+ headers.update(extra_headers)
362
+
363
+ client = await self._get_client()
364
+ last_error: Optional[str] = None
365
+ last_status: int = 0
366
+ for attempt in range(1 + _settings.gcs_max_retries):
367
+ try:
368
+ response = await client.request(
369
+ method,
370
+ url,
371
+ params=params,
372
+ json=json_body,
373
+ headers=headers,
374
+ content=content,
375
+ )
376
+ if response.status_code < 400:
377
+ return self._success_result(response)
378
+
379
+ last_status = response.status_code
380
+ last_error = self._format_gcs_error(response)
381
+ _logger.warning(
382
+ "GCS API error on %s %s: HTTP %s -> %s",
383
+ method, path, response.status_code, last_error,
384
+ )
385
+
386
+ if response.status_code in _RETRYABLE_STATUS:
387
+ if attempt < _settings.gcs_max_retries:
388
+ await asyncio.sleep(2.0 ** attempt)
389
+ continue
390
+ if 400 <= response.status_code < 500:
391
+ raise GCSError(last_error, status_code=response.status_code)
392
+
393
+ except GCSError:
394
+ raise
395
+ except httpx.TimeoutException:
396
+ last_error = "Request timed out"
397
+ _logger.warning("GCS API timeout on %s %s (attempt %d/%d)", method, path, attempt + 1, 1 + _settings.gcs_max_retries)
398
+ except httpx.RequestError as exc:
399
+ last_error = f"Request failed: {exc}"
400
+ _logger.warning("GCS API request error on %s %s: %s (attempt %d/%d)", method, path, last_error, attempt + 1, 1 + _settings.gcs_max_retries)
401
+ except Exception as exc:
402
+ last_error = f"Unexpected error: {exc}"
403
+ _logger.error("GCS API unexpected error on %s %s: %s", method, path, last_error)
404
+ break
405
+
406
+ if attempt < _settings.gcs_max_retries:
407
+ await asyncio.sleep(1.0 * (attempt + 1))
408
+
409
+ raise GCSError(last_error or f"GCS API error (HTTP {last_status}).", status_code=502)
410
+
411
+ @staticmethod
412
+ def _success_result(response: httpx.Response) -> Dict[str, Any]:
413
+ content_type = response.headers.get("content-type", "")
414
+ result: Dict[str, Any] = {
415
+ "success": True,
416
+ "status_code": response.status_code,
417
+ "content_type": content_type,
418
+ "headers": dict(response.headers),
419
+ "error": None,
420
+ }
421
+ if response.content and content_type.startswith("application/json"):
422
+ result["data"] = response.json()
423
+ else:
424
+ result["data"] = response.content
425
+ return result
426
+
427
+ @staticmethod
428
+ def _format_gcs_error(response: httpx.Response) -> str:
429
+ status = response.status_code
430
+ message = ""
431
+ reason = ""
432
+ try:
433
+ body = response.json()
434
+ error = body.get("error", {}) if isinstance(body, dict) else {}
435
+ message = error.get("message", "") or ""
436
+ errors = error.get("errors", [])
437
+ if errors and isinstance(errors[0], dict):
438
+ reason = errors[0].get("reason", "") or ""
439
+ except Exception:
440
+ pass
441
+
442
+ if status == 401:
443
+ return "Invalid or expired Google service account credentials."
444
+ if status == 403:
445
+ if "permission" in message.lower() or reason in ("forbidden", "required", "storagePermissionDenied"):
446
+ return "Permission denied. The service account lacks the required IAM role for this operation."
447
+ return f"Access forbidden: {message}".rstrip(".")
448
+ if status == 404:
449
+ return "The requested bucket or object was not found."
450
+ if status == 409:
451
+ return "Conflict. A bucket or object with the same name already exists, or a generation condition failed."
452
+ if status == 429:
453
+ return "API rate limit exceeded. Please wait and retry."
454
+ if message:
455
+ return message.rstrip(".") + "."
456
+ return f"Google Cloud Storage error (HTTP {status})."
457
+
458
+ # ------------------------------------------------------------------
459
+ # Buckets
460
+ # ------------------------------------------------------------------
461
+
462
+ async def create_bucket(
463
+ self,
464
+ creds: GCSCredentials,
465
+ name: str,
466
+ *,
467
+ project: Optional[str] = None,
468
+ bucket_body: Optional[Dict[str, Any]] = None,
469
+ ) -> Dict[str, Any]:
470
+ project_id = project or creds.project_id
471
+ body: Dict[str, Any] = dict(bucket_body or {})
472
+ body["name"] = name
473
+ params = {"project": project_id}
474
+ return await self._request("POST", "/b", creds, params=params, json_body=body)
475
+
476
+ async def get_bucket(self, creds: GCSCredentials, bucket: str) -> Dict[str, Any]:
477
+ return await self._request("GET", f"/b/{quote(bucket, safe='')}", creds)
478
+
479
+ async def list_buckets(
480
+ self,
481
+ creds: GCSCredentials,
482
+ *,
483
+ project: Optional[str] = None,
484
+ prefix: Optional[str] = None,
485
+ max_results: Optional[int] = None,
486
+ page_token: Optional[str] = None,
487
+ ) -> Dict[str, Any]:
488
+ params: Dict[str, Any] = {"project": project or creds.project_id}
489
+ if prefix:
490
+ params["prefix"] = prefix
491
+ if max_results is not None:
492
+ params["maxResults"] = max_results
493
+ if page_token:
494
+ params["pageToken"] = page_token
495
+ return await self._request("GET", "/b", creds, params=params)
496
+
497
+ async def patch_bucket(
498
+ self, creds: GCSCredentials, bucket: str, body: Dict[str, Any],
499
+ *, if_metageneration_match: Optional[int] = None,
500
+ ) -> Dict[str, Any]:
501
+ params = {"ifMetagenerationMatch": if_metageneration_match} if if_metageneration_match is not None else None
502
+ return await self._request("PATCH", f"/b/{quote(bucket, safe='')}", creds, params=params, json_body=body)
503
+
504
+ async def update_bucket(
505
+ self, creds: GCSCredentials, bucket: str, body: Dict[str, Any],
506
+ *, if_metageneration_match: Optional[int] = None,
507
+ ) -> Dict[str, Any]:
508
+ params = {"ifMetagenerationMatch": if_metageneration_match} if if_metageneration_match is not None else None
509
+ return await self._request("PUT", f"/b/{quote(bucket, safe='')}", creds, params=params, json_body=body)
510
+
511
+ async def delete_bucket(
512
+ self, creds: GCSCredentials, bucket: str,
513
+ *, if_metageneration_match: Optional[int] = None,
514
+ ) -> Dict[str, Any]:
515
+ params = {"ifMetagenerationMatch": if_metageneration_match} if if_metageneration_match is not None else None
516
+ return await self._request("DELETE", f"/b/{quote(bucket, safe='')}", creds, params=params)
517
+
518
+ # ------------------------------------------------------------------
519
+ # Bucket IAM & permissions
520
+ # ------------------------------------------------------------------
521
+
522
+ async def get_bucket_iam(self, creds: GCSCredentials, bucket: str) -> Dict[str, Any]:
523
+ return await self._request("GET", f"/b/{quote(bucket, safe='')}/iam", creds)
524
+
525
+ async def set_bucket_iam(
526
+ self, creds: GCSCredentials, bucket: str, policy: Dict[str, Any],
527
+ ) -> Dict[str, Any]:
528
+ return await self._request("PUT", f"/b/{quote(bucket, safe='')}/iam", creds, json_body=policy)
529
+
530
+ async def test_bucket_permissions(
531
+ self, creds: GCSCredentials, bucket: str, permissions: List[str],
532
+ ) -> Dict[str, Any]:
533
+ params = {"permissions": ",".join(permissions)}
534
+ return await self._request("GET", f"/b/{quote(bucket, safe='')}/iam/testPermissions", creds, params=params)
535
+
536
+ # ------------------------------------------------------------------
537
+ # Default object ACLs
538
+ # ------------------------------------------------------------------
539
+
540
+ async def list_default_object_acl(self, creds: GCSCredentials, bucket: str) -> Dict[str, Any]:
541
+ return await self._request("GET", f"/b/{quote(bucket, safe='')}/defaultObjectAcl", creds)
542
+
543
+ async def insert_default_object_acl(
544
+ self, creds: GCSCredentials, bucket: str, body: Dict[str, Any],
545
+ ) -> Dict[str, Any]:
546
+ return await self._request("POST", f"/b/{quote(bucket, safe='')}/defaultObjectAcl", creds, json_body=body)
547
+
548
+ async def get_default_object_acl(
549
+ self, creds: GCSCredentials, bucket: str, entity: str,
550
+ ) -> Dict[str, Any]:
551
+ return await self._request(
552
+ "GET", f"/b/{quote(bucket, safe='')}/defaultObjectAcl/{quote(entity, safe='')}", creds
553
+ )
554
+
555
+ async def patch_default_object_acl(
556
+ self, creds: GCSCredentials, bucket: str, entity: str, body: Dict[str, Any],
557
+ ) -> Dict[str, Any]:
558
+ return await self._request(
559
+ "PATCH", f"/b/{quote(bucket, safe='')}/defaultObjectAcl/{quote(entity, safe='')}", creds, json_body=body
560
+ )
561
+
562
+ async def update_default_object_acl(
563
+ self, creds: GCSCredentials, bucket: str, entity: str, body: Dict[str, Any],
564
+ ) -> Dict[str, Any]:
565
+ return await self._request(
566
+ "PUT", f"/b/{quote(bucket, safe='')}/defaultObjectAcl/{quote(entity, safe='')}", creds, json_body=body
567
+ )
568
+
569
+ async def delete_default_object_acl(
570
+ self, creds: GCSCredentials, bucket: str, entity: str,
571
+ ) -> Dict[str, Any]:
572
+ return await self._request(
573
+ "DELETE", f"/b/{quote(bucket, safe='')}/defaultObjectAcl/{quote(entity, safe='')}", creds
574
+ )
575
+
576
+ # ------------------------------------------------------------------
577
+ # Objects
578
+ # ------------------------------------------------------------------
579
+
580
+ async def list_objects(
581
+ self,
582
+ creds: GCSCredentials,
583
+ bucket: str,
584
+ *,
585
+ prefix: Optional[str] = None,
586
+ delimiter: Optional[str] = None,
587
+ max_results: Optional[int] = None,
588
+ page_token: Optional[str] = None,
589
+ versions: Optional[bool] = None,
590
+ match_glob: Optional[str] = None,
591
+ start_offset: Optional[str] = None,
592
+ end_offset: Optional[str] = None,
593
+ include_trailing_delimiter: Optional[bool] = None,
594
+ ) -> Dict[str, Any]:
595
+ params: Dict[str, Any] = {}
596
+ if prefix is not None:
597
+ params["prefix"] = prefix
598
+ if delimiter is not None:
599
+ params["delimiter"] = delimiter
600
+ if max_results is not None:
601
+ params["maxResults"] = max_results
602
+ if page_token:
603
+ params["pageToken"] = page_token
604
+ if versions is not None:
605
+ params["versions"] = versions
606
+ if match_glob:
607
+ params["matchGlob"] = match_glob
608
+ if start_offset is not None:
609
+ params["startOffset"] = start_offset
610
+ if end_offset is not None:
611
+ params["endOffset"] = end_offset
612
+ if include_trailing_delimiter is not None:
613
+ params["includeTrailingDelimiter"] = include_trailing_delimiter
614
+ return await self._request("GET", f"/b/{quote(bucket, safe='')}/o", creds, params=params)
615
+
616
+ async def upload_object(
617
+ self,
618
+ creds: GCSCredentials,
619
+ bucket: str,
620
+ name: str,
621
+ content: bytes,
622
+ *,
623
+ content_type: str = "application/octet-stream",
624
+ metadata: Optional[Dict[str, Any]] = None,
625
+ if_generation_match: Optional[int] = None,
626
+ ) -> Dict[str, Any]:
627
+ params: Dict[str, Any] = {"uploadType": "media", "name": name}
628
+ if if_generation_match is not None:
629
+ params["ifGenerationMatch"] = if_generation_match
630
+ if metadata:
631
+ for key, value in metadata.items():
632
+ params[key] = value
633
+ headers = {"Content-Type": content_type}
634
+ return await self._request(
635
+ "POST", f"/b/{quote(bucket, safe='')}/o", creds,
636
+ params=params, content=content, extra_headers=headers, upload=True,
637
+ )
638
+
639
+ async def download_object(
640
+ self,
641
+ creds: GCSCredentials,
642
+ bucket: str,
643
+ name: str,
644
+ *,
645
+ generation: Optional[int] = None,
646
+ ) -> Dict[str, Any]:
647
+ params: Dict[str, Any] = {"alt": "media"}
648
+ if generation is not None:
649
+ params["generation"] = generation
650
+ return await self._request(
651
+ "GET", f"/b/{quote(bucket, safe='')}/o/{quote(name, safe='')}", creds, params=params
652
+ )
653
+
654
+ async def get_object(
655
+ self,
656
+ creds: GCSCredentials,
657
+ bucket: str,
658
+ name: str,
659
+ *,
660
+ generation: Optional[int] = None,
661
+ ) -> Dict[str, Any]:
662
+ params = {"generation": generation} if generation is not None else None
663
+ return await self._request("GET", f"/b/{quote(bucket, safe='')}/o/{quote(name, safe='')}", creds, params=params)
664
+
665
+ async def patch_object(
666
+ self, creds: GCSCredentials, bucket: str, name: str, body: Dict[str, Any],
667
+ *, if_generation_match: Optional[int] = None,
668
+ ) -> Dict[str, Any]:
669
+ params = {"ifGenerationMatch": if_generation_match} if if_generation_match is not None else None
670
+ return await self._request(
671
+ "PATCH", f"/b/{quote(bucket, safe='')}/o/{quote(name, safe='')}", creds, params=params, json_body=body
672
+ )
673
+
674
+ async def update_object(
675
+ self, creds: GCSCredentials, bucket: str, name: str, body: Dict[str, Any],
676
+ *, if_generation_match: Optional[int] = None,
677
+ ) -> Dict[str, Any]:
678
+ params = {"ifGenerationMatch": if_generation_match} if if_generation_match is not None else None
679
+ return await self._request(
680
+ "PUT", f"/b/{quote(bucket, safe='')}/o/{quote(name, safe='')}", creds, params=params, json_body=body
681
+ )
682
+
683
+ async def delete_object(
684
+ self,
685
+ creds: GCSCredentials,
686
+ bucket: str,
687
+ name: str,
688
+ *,
689
+ generation: Optional[int] = None,
690
+ if_generation_match: Optional[int] = None,
691
+ ) -> Dict[str, Any]:
692
+ params: Dict[str, Any] = {}
693
+ if generation is not None:
694
+ params["generation"] = generation
695
+ if if_generation_match is not None:
696
+ params["ifGenerationMatch"] = if_generation_match
697
+ params = params or None
698
+ return await self._request("DELETE", f"/b/{quote(bucket, safe='')}/o/{quote(name, safe='')}", creds, params=params)
699
+
700
+ async def copy_object(
701
+ self,
702
+ creds: GCSCredentials,
703
+ source_bucket: str,
704
+ source_name: str,
705
+ destination_bucket: str,
706
+ destination_name: str,
707
+ *,
708
+ source_generation: Optional[int] = None,
709
+ body: Optional[Dict[str, Any]] = None,
710
+ ) -> Dict[str, Any]:
711
+ path = (
712
+ f"/b/{quote(source_bucket, safe='')}/o/{quote(source_name, safe='')}"
713
+ f"/copyTo/b/{quote(destination_bucket, safe='')}/o/{quote(destination_name, safe='')}"
714
+ )
715
+ params = {"sourceGeneration": source_generation} if source_generation is not None else None
716
+ return await self._request("POST", path, creds, params=params, json_body=body)
717
+
718
+ async def move_object(
719
+ self,
720
+ creds: GCSCredentials,
721
+ source_bucket: str,
722
+ source_name: str,
723
+ destination_bucket: str,
724
+ destination_name: str,
725
+ *,
726
+ source_generation: Optional[int] = None,
727
+ if_generation_match: Optional[int] = None,
728
+ body: Optional[Dict[str, Any]] = None,
729
+ ) -> Dict[str, Any]:
730
+ path = (
731
+ f"/b/{quote(source_bucket, safe='')}/o/{quote(source_name, safe='')}"
732
+ f"/moveTo/b/{quote(destination_bucket, safe='')}/o/{quote(destination_name, safe='')}"
733
+ )
734
+ params: Dict[str, Any] = {}
735
+ if source_generation is not None:
736
+ params["sourceGeneration"] = source_generation
737
+ if if_generation_match is not None:
738
+ params["ifGenerationMatch"] = if_generation_match
739
+ return await self._request("POST", path, creds, params=params or None, json_body=body)
740
+
741
+ async def rewrite_object(
742
+ self,
743
+ creds: GCSCredentials,
744
+ source_bucket: str,
745
+ source_name: str,
746
+ destination_bucket: str,
747
+ destination_name: str,
748
+ *,
749
+ rewrite_token: Optional[str] = None,
750
+ max_bytes_rewritten_per_call: Optional[int] = None,
751
+ body: Optional[Dict[str, Any]] = None,
752
+ ) -> Dict[str, Any]:
753
+ path = (
754
+ f"/b/{quote(source_bucket, safe='')}/o/{quote(source_name, safe='')}"
755
+ f"/rewriteTo/b/{quote(destination_bucket, safe='')}/o/{quote(destination_name, safe='')}"
756
+ )
757
+ params: Dict[str, Any] = {}
758
+ if rewrite_token:
759
+ params["rewriteToken"] = rewrite_token
760
+ if max_bytes_rewritten_per_call is not None:
761
+ params["maxBytesRewrittenPerCall"] = max_bytes_rewritten_per_call
762
+ return await self._request("POST", path, creds, params=params or None, json_body=body)
763
+
764
+ async def compose_object(
765
+ self,
766
+ creds: GCSCredentials,
767
+ bucket: str,
768
+ destination_name: str,
769
+ source_objects: List[str],
770
+ *,
771
+ body: Optional[Dict[str, Any]] = None,
772
+ ) -> Dict[str, Any]:
773
+ compose_body: Dict[str, Any] = {
774
+ "sourceObjects": [{"name": name} for name in source_objects]
775
+ }
776
+ if body:
777
+ compose_body["destination"] = body
778
+ path = f"/b/{quote(bucket, safe='')}/o/{quote(destination_name, safe='')}/compose"
779
+ return await self._request("POST", path, creds, json_body=compose_body)
780
+
781
+ async def initiate_resumable_upload(
782
+ self,
783
+ creds: GCSCredentials,
784
+ bucket: str,
785
+ name: str,
786
+ *,
787
+ content_type: str = "application/octet-stream",
788
+ object_metadata: Optional[Dict[str, Any]] = None,
789
+ ) -> Dict[str, Any]:
790
+ """Create a resumable upload session and return its URI.
791
+
792
+ The client can then ``PUT`` the object bytes to ``session_uri`` (optionally
793
+ in chunks with ``Content-Range``) to complete the upload.
794
+ """
795
+ params: Dict[str, Any] = {"uploadType": "resumable", "name": name}
796
+ body: Dict[str, Any] = dict(object_metadata or {})
797
+ body.setdefault("name", name)
798
+ body.setdefault("contentType", content_type)
799
+ result = await self._request(
800
+ "POST", f"/b/{quote(bucket, safe='')}/o", creds,
801
+ params=params, json_body=body,
802
+ extra_headers={"Content-Type": "application/json; charset=UTF-8"},
803
+ upload=True,
804
+ )
805
+ session_uri = (result.get("headers") or {}).get("location")
806
+ return {
807
+ "success": True,
808
+ "session_uri": session_uri,
809
+ "data": result.get("data"),
810
+ "error": None,
811
+ }
812
+
813
+ # ------------------------------------------------------------------
814
+ # Object holds & retention policy
815
+ # ------------------------------------------------------------------
816
+
817
+ async def set_object_hold(
818
+ self, creds: GCSCredentials, bucket: str, name: str,
819
+ ) -> Dict[str, Any]:
820
+ return await self._request(
821
+ "POST", f"/b/{quote(bucket, safe='')}/o/{quote(name, safe='')}/hold", creds
822
+ )
823
+
824
+ async def release_object_hold(
825
+ self, creds: GCSCredentials, bucket: str, name: str,
826
+ ) -> Dict[str, Any]:
827
+ return await self._request(
828
+ "POST", f"/b/{quote(bucket, safe='')}/o/{quote(name, safe='')}/releaseHold", creds
829
+ )
830
+
831
+ async def lock_object_retention(
832
+ self,
833
+ creds: GCSCredentials,
834
+ bucket: str,
835
+ name: str,
836
+ *,
837
+ if_metageneration_match: Optional[int] = None,
838
+ ) -> Dict[str, Any]:
839
+ params = {"ifMetagenerationMatch": if_metageneration_match} if if_metageneration_match is not None else None
840
+ return await self._request(
841
+ "POST", f"/b/{quote(bucket, safe='')}/o/{quote(name, safe='')}/lockRetentionPolicy",
842
+ creds, params=params,
843
+ )
844
+
845
+ # ------------------------------------------------------------------
846
+ # Notifications (object change watch)
847
+ # ------------------------------------------------------------------
848
+
849
+ async def watch_all_objects(
850
+ self, creds: GCSCredentials, bucket: str, channel_body: Dict[str, Any],
851
+ ) -> Dict[str, Any]:
852
+ return await self._request(
853
+ "POST", f"/b/{quote(bucket, safe='')}/o/watch", creds, json_body=channel_body
854
+ )
855
+
856
+ # ------------------------------------------------------------------
857
+ # Bucket restore (soft-deleted buckets)
858
+ # ------------------------------------------------------------------
859
+
860
+ async def restore_bucket(
861
+ self,
862
+ creds: GCSCredentials,
863
+ bucket: str,
864
+ *,
865
+ if_metageneration_match: Optional[int] = None,
866
+ ) -> Dict[str, Any]:
867
+ params = {"ifMetagenerationMatch": if_metageneration_match} if if_metageneration_match is not None else None
868
+ return await self._request(
869
+ "POST", f"/b/{quote(bucket, safe='')}/restore", creds, params=params
870
+ )
871
+
872
+ # ------------------------------------------------------------------
873
+ # Object IAM
874
+ # ------------------------------------------------------------------
875
+
876
+ async def get_object_iam(
877
+ self, creds: GCSCredentials, bucket: str, name: str,
878
+ ) -> Dict[str, Any]:
879
+ return await self._request(
880
+ "GET", f"/b/{quote(bucket, safe='')}/o/{quote(name, safe='')}/iam", creds
881
+ )
882
+
883
+ async def set_object_iam(
884
+ self, creds: GCSCredentials, bucket: str, name: str, policy: Dict[str, Any],
885
+ ) -> Dict[str, Any]:
886
+ return await self._request(
887
+ "PUT", f"/b/{quote(bucket, safe='')}/o/{quote(name, safe='')}/iam", creds, json_body=policy
888
+ )
889
+
890
+ # ------------------------------------------------------------------
891
+ # Object ACLs
892
+ # ------------------------------------------------------------------
893
+
894
+ async def list_object_acl(
895
+ self, creds: GCSCredentials, bucket: str, name: str,
896
+ ) -> Dict[str, Any]:
897
+ return await self._request(
898
+ "GET", f"/b/{quote(bucket, safe='')}/o/{quote(name, safe='')}/acl", creds
899
+ )
900
+
901
+ async def insert_object_acl(
902
+ self, creds: GCSCredentials, bucket: str, name: str, body: Dict[str, Any],
903
+ ) -> Dict[str, Any]:
904
+ return await self._request(
905
+ "POST", f"/b/{quote(bucket, safe='')}/o/{quote(name, safe='')}/acl", creds, json_body=body
906
+ )
907
+
908
+ async def get_object_acl(
909
+ self, creds: GCSCredentials, bucket: str, name: str, entity: str,
910
+ ) -> Dict[str, Any]:
911
+ return await self._request(
912
+ "GET", f"/b/{quote(bucket, safe='')}/o/{quote(name, safe='')}/acl/{quote(entity, safe='')}", creds
913
+ )
914
+
915
+ async def patch_object_acl(
916
+ self, creds: GCSCredentials, bucket: str, name: str, entity: str, body: Dict[str, Any],
917
+ ) -> Dict[str, Any]:
918
+ return await self._request(
919
+ "PATCH", f"/b/{quote(bucket, safe='')}/o/{quote(name, safe='')}/acl/{quote(entity, safe='')}",
920
+ creds, json_body=body,
921
+ )
922
+
923
+ async def update_object_acl(
924
+ self, creds: GCSCredentials, bucket: str, name: str, entity: str, body: Dict[str, Any],
925
+ ) -> Dict[str, Any]:
926
+ return await self._request(
927
+ "PUT", f"/b/{quote(bucket, safe='')}/o/{quote(name, safe='')}/acl/{quote(entity, safe='')}",
928
+ creds, json_body=body,
929
+ )
930
+
931
+ async def delete_object_acl(
932
+ self, creds: GCSCredentials, bucket: str, name: str, entity: str,
933
+ ) -> Dict[str, Any]:
934
+ return await self._request(
935
+ "DELETE", f"/b/{quote(bucket, safe='')}/o/{quote(name, safe='')}/acl/{quote(entity, safe='')}", creds
936
+ )
937
+
938
+ # ------------------------------------------------------------------
939
+ # Public URLs
940
+ # ------------------------------------------------------------------
941
+
942
+ @staticmethod
943
+ def public_url(bucket: str, name: str) -> str:
944
+ return f"https://storage.googleapis.com/{bucket}/{name}"
945
+
946
+ # ------------------------------------------------------------------
947
+ # Signed URLs (V4, RSA-SHA256)
948
+ # ------------------------------------------------------------------
949
+
950
+ def sign_url_v4(
951
+ self,
952
+ creds: GCSCredentials,
953
+ method: str,
954
+ bucket: str,
955
+ name: str,
956
+ *,
957
+ expires_in_seconds: Optional[int] = None,
958
+ content_type: Optional[str] = None,
959
+ query_params: Optional[Dict[str, str]] = None,
960
+ response_content_type: Optional[str] = None,
961
+ response_disposition: Optional[str] = None,
962
+ ) -> Tuple[str, int]:
963
+ """Generate a V4 signed URL for a single GCS operation.
964
+
965
+ Returns ``(signed_url, effective_expires_seconds)``.
966
+ """
967
+ expires = (
968
+ expires_in_seconds
969
+ if expires_in_seconds is not None
970
+ else _settings.gcs_default_expires_seconds
971
+ )
972
+ if expires <= 0:
973
+ raise GCSError("expires_in_seconds must be greater than 0.", status_code=400)
974
+ if expires > _settings.gcs_max_expires_seconds:
975
+ raise GCSError(
976
+ f"expires_in_seconds cannot exceed {_settings.gcs_max_expires_seconds} seconds (7 days).",
977
+ status_code=400,
978
+ )
979
+
980
+ now = datetime.now(timezone.utc)
981
+ request_timestamp = now.strftime("%Y%m%dT%H%M%SZ")
982
+ datestamp = now.strftime("%Y%m%d")
983
+ credential_scope = f"{datestamp}/{_SIGNING_REGION}/{_SIGNING_SERVICE}/goog4_request"
984
+ credential = f"{creds.client_email}/{credential_scope}"
985
+
986
+ host = "storage.googleapis.com"
987
+ canonical_uri = f"/{bucket}/{name}"
988
+
989
+ headers: Dict[str, str] = {"host": host}
990
+ if content_type:
991
+ headers["content-type"] = content_type
992
+
993
+ ordered_headers = {k.lower(): str(v).lower() for k, v in sorted(headers.items())}
994
+ canonical_headers = "".join(f"{k}:{v}\n" for k, v in ordered_headers.items())
995
+ signed_headers = ";".join(ordered_headers.keys())
996
+
997
+ query: Dict[str, Any] = {
998
+ "X-Goog-Algorithm": _SIGNING_ALGORITHM,
999
+ "X-Goog-Credential": credential,
1000
+ "X-Goog-Date": request_timestamp,
1001
+ "X-Goog-Expires": str(expires),
1002
+ "X-Goog-SignedHeaders": signed_headers,
1003
+ }
1004
+ if response_content_type:
1005
+ query["response-content-type"] = response_content_type
1006
+ if response_disposition:
1007
+ query["response-content-disposition"] = response_disposition
1008
+ if query_params:
1009
+ query.update(query_params)
1010
+
1011
+ canonical_query_string = "&".join(
1012
+ f"{quote(str(k), safe='')}={quote(str(v), safe='')}"
1013
+ for k, v in sorted(query.items())
1014
+ )
1015
+
1016
+ canonical_request = "\n".join([
1017
+ method.upper(),
1018
+ canonical_uri,
1019
+ canonical_query_string,
1020
+ canonical_headers,
1021
+ signed_headers,
1022
+ "UNSIGNED-PAYLOAD",
1023
+ ])
1024
+
1025
+ canonical_request_hash = hashlib.sha256(canonical_request.encode("utf-8")).hexdigest()
1026
+ string_to_sign = "\n".join([
1027
+ _SIGNING_ALGORITHM,
1028
+ request_timestamp,
1029
+ credential_scope,
1030
+ canonical_request_hash,
1031
+ ])
1032
+
1033
+ from cryptography.hazmat.primitives import hashes
1034
+ from cryptography.hazmat.primitives.asymmetric import padding
1035
+ from cryptography.hazmat.primitives.serialization import load_pem_private_key
1036
+
1037
+ private_key = load_pem_private_key(creds.private_key.encode("utf-8"), password=None)
1038
+ signature = private_key.sign(string_to_sign.encode("utf-8"), padding.PKCS1v15(), hashes.SHA256())
1039
+ signature_hex = signature.hex()
1040
+
1041
+ signed_url = (
1042
+ f"https://{host}{canonical_uri}?{canonical_query_string}"
1043
+ f"&X-Goog-Signature={signature_hex}"
1044
+ )
1045
+ return signed_url, expires
1046
+
1047
+ async def download_signed_url(
1048
+ self,
1049
+ creds: GCSCredentials,
1050
+ bucket: str,
1051
+ name: str,
1052
+ *,
1053
+ expires_in_seconds: Optional[int] = None,
1054
+ response_content_type: Optional[str] = None,
1055
+ response_disposition: Optional[str] = None,
1056
+ ) -> Tuple[str, int]:
1057
+ return self.sign_url_v4(
1058
+ creds, "GET", bucket, name,
1059
+ expires_in_seconds=expires_in_seconds,
1060
+ response_content_type=response_content_type,
1061
+ response_disposition=response_disposition,
1062
+ )
1063
+
1064
+ async def upload_signed_url(
1065
+ self,
1066
+ creds: GCSCredentials,
1067
+ bucket: str,
1068
+ name: str,
1069
+ *,
1070
+ expires_in_seconds: Optional[int] = None,
1071
+ content_type: Optional[str] = None,
1072
+ ) -> Tuple[str, int]:
1073
+ return self.sign_url_v4(
1074
+ creds, "PUT", bucket, name,
1075
+ expires_in_seconds=expires_in_seconds,
1076
+ content_type=content_type,
1077
+ )