validops-east-1 commited on
Commit
8ec8b4a
·
1 Parent(s): 548dfc9

feat: google sheets api integration

Browse files
app/api/server.py CHANGED
@@ -149,6 +149,8 @@ async def lifespan(app: FastAPI):
149
  await close_gcs_service()
150
  from app.api.v1.gmail import close_gmail_service
151
  await close_gmail_service()
 
 
152
  from app.services.media_storage_service import close_storage_service
153
  await close_storage_service()
154
  from app.utils.http_utils import close_shared_aiohttp_sessions
 
149
  await close_gcs_service()
150
  from app.api.v1.gmail import close_gmail_service
151
  await close_gmail_service()
152
+ from app.api.v1.sheets import close_sheets_service
153
+ await close_sheets_service()
154
  from app.services.media_storage_service import close_storage_service
155
  await close_storage_service()
156
  from app.utils.http_utils import close_shared_aiohttp_sessions
app/api/v1/router.py CHANGED
@@ -25,6 +25,7 @@ from app.api.v1 import (
25
  scopes,
26
  scraper,
27
  semantic_router,
 
28
  sql_validator,
29
  system,
30
  token_counter,
@@ -62,6 +63,7 @@ api_v1_router.include_router(google_oauth.router, tags=["Google OAuth"])
62
  api_v1_router.include_router(scopes.router, tags=["Google Scopes"])
63
  api_v1_router.include_router(gcs.router, tags=["Google Cloud Storage"])
64
  api_v1_router.include_router(gmail.router, tags=["Gmail"])
 
65
  api_v1_router.include_router(media_convert.router, tags=["Media-to-Media Conversion"])
66
  api_v1_router.include_router(json_extract.router, tags=["JSON Extractor"])
67
  api_v1_router.include_router(keys_extract.router, prefix="/json", tags=["Keys Extractor"])
 
25
  scopes,
26
  scraper,
27
  semantic_router,
28
+ sheets,
29
  sql_validator,
30
  system,
31
  token_counter,
 
63
  api_v1_router.include_router(scopes.router, tags=["Google Scopes"])
64
  api_v1_router.include_router(gcs.router, tags=["Google Cloud Storage"])
65
  api_v1_router.include_router(gmail.router, tags=["Gmail"])
66
+ api_v1_router.include_router(sheets.router, tags=["Google Sheets"])
67
  api_v1_router.include_router(media_convert.router, tags=["Media-to-Media Conversion"])
68
  api_v1_router.include_router(json_extract.router, tags=["JSON Extractor"])
69
  api_v1_router.include_router(keys_extract.router, prefix="/json", tags=["Keys Extractor"])
app/api/v1/sheets.py ADDED
@@ -0,0 +1,364 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Any, Dict, Optional
5
+
6
+ from fastapi import APIRouter, Depends, Header, HTTPException
7
+
8
+ from app.config import get_settings
9
+ from app.core.logger import get_logger
10
+ from app.models.schemas import (
11
+ SheetsAppendRequest,
12
+ SheetsBatchUpdateRequest,
13
+ SheetsCreateSpreadsheetRequest,
14
+ SheetsGenericResponse,
15
+ SheetsRefreshResponse,
16
+ SheetsValueUpdateRequest,
17
+ )
18
+ from app.services.sheets_service import (
19
+ SheetsAPIError,
20
+ SheetsCredentials,
21
+ SheetsService,
22
+ )
23
+
24
+ router = APIRouter(prefix="/google/sheets", tags=["Google Sheets"])
25
+ _logger = get_logger(__name__)
26
+ _settings = get_settings()
27
+
28
+ _sheets_service = SheetsService()
29
+
30
+
31
+ def get_sheets_service() -> SheetsService:
32
+ return _sheets_service
33
+
34
+
35
+ async def close_sheets_service() -> None:
36
+ await _sheets_service.close()
37
+
38
+
39
+ def _credentials(
40
+ x_access_token: Optional[str] = Header(None, alias="X-Access-Token"),
41
+ x_refresh_token: Optional[str] = Header(None, alias="X-Refresh-Token"),
42
+ x_client_id: Optional[str] = Header(None, alias="X-Client-Id"),
43
+ x_client_secret: Optional[str] = Header(None, alias="X-Client-Secret"),
44
+ x_token_expires_at: Optional[float] = Header(None, alias="X-Token-Expires-At"),
45
+ ) -> SheetsCredentials:
46
+ if not x_access_token:
47
+ raise HTTPException(
48
+ status_code=401,
49
+ detail="X-Access-Token header is required.",
50
+ )
51
+ return SheetsCredentials(
52
+ access_token=x_access_token,
53
+ refresh_token=x_refresh_token,
54
+ client_id=x_client_id,
55
+ client_secret=x_client_secret,
56
+ expires_at=x_token_expires_at,
57
+ )
58
+
59
+
60
+ def _http_error(exc: SheetsAPIError) -> HTTPException:
61
+ return HTTPException(status_code=exc.status_code, detail=exc.message)
62
+
63
+
64
+ def _elapsed_ms(start: float) -> float:
65
+ return round((time.perf_counter() - start) * 1000, 2)
66
+
67
+
68
+ def _ok(
69
+ start: float,
70
+ creds: SheetsCredentials,
71
+ data: Any = None,
72
+ *,
73
+ error: Optional[str] = None,
74
+ ) -> SheetsGenericResponse:
75
+ return SheetsGenericResponse(
76
+ success=error is None,
77
+ time_ms=_elapsed_ms(start),
78
+ data=data,
79
+ refreshed_access_token=creds.refreshed_access_token,
80
+ error=error,
81
+ )
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Service metadata & token lifecycle
86
+ # ---------------------------------------------------------------------------
87
+
88
+ @router.get("/scopes", response_model=SheetsGenericResponse,
89
+ summary="List all supported Google Sheets OAuth scopes")
90
+ async def list_scopes(
91
+ service: SheetsService = Depends(get_sheets_service),
92
+ ):
93
+ start = time.perf_counter()
94
+ scopes = await service.list_available_scopes()
95
+ _logger.info("Listed %d Sheets scopes (%.2fms)", len(scopes), _elapsed_ms(start))
96
+ return SheetsGenericResponse(success=True, time_ms=_elapsed_ms(start), data=scopes)
97
+
98
+
99
+ @router.post("/token/refresh", response_model=SheetsRefreshResponse,
100
+ summary="Refresh an access token (stateless, returned to the client)")
101
+ async def token_refresh(
102
+ body: Dict[str, str],
103
+ service: SheetsService = Depends(get_sheets_service),
104
+ ):
105
+ start = time.perf_counter()
106
+ client_id = body.get("client_id", "")
107
+ client_secret = body.get("client_secret", "")
108
+ refresh_token = body.get("refresh_token", "")
109
+ if not (client_id and client_secret and refresh_token):
110
+ raise HTTPException(
111
+ status_code=400,
112
+ detail="client_id, client_secret and refresh_token are required.",
113
+ )
114
+ try:
115
+ tokens = await service.refresh_access_token(
116
+ client_id=client_id,
117
+ client_secret=client_secret,
118
+ refresh_token=refresh_token,
119
+ )
120
+ except SheetsAPIError as exc:
121
+ raise _http_error(exc) from exc
122
+ _logger.info("Sheets token refreshed (%.2fms)", _elapsed_ms(start))
123
+ return SheetsRefreshResponse(
124
+ success=True,
125
+ access_token=tokens.get("access_token"),
126
+ expires_in=int(tokens.get("expires_in", 0)),
127
+ token_type=tokens.get("token_type", "Bearer"),
128
+ scope=tokens.get("scope"),
129
+ )
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # Spreadsheets
134
+ # ---------------------------------------------------------------------------
135
+
136
+ @router.post("/spreadsheets", response_model=SheetsGenericResponse,
137
+ summary="Create a new spreadsheet")
138
+ async def create_spreadsheet(
139
+ body: SheetsCreateSpreadsheetRequest,
140
+ creds: SheetsCredentials = Depends(_credentials),
141
+ service: SheetsService = Depends(get_sheets_service),
142
+ ):
143
+ start = time.perf_counter()
144
+ try:
145
+ data = await service.create_spreadsheet(
146
+ creds,
147
+ title=body.title,
148
+ sheets=body.sheets,
149
+ properties=body.properties,
150
+ )
151
+ except SheetsAPIError as exc:
152
+ raise _http_error(exc) from exc
153
+ _logger.info("Sheets spreadsheet '%s' created (%.2fms)", body.title, _elapsed_ms(start))
154
+ return _ok(start, creds, data)
155
+
156
+
157
+ @router.get("/spreadsheets/{spreadsheet_id}", response_model=SheetsGenericResponse,
158
+ summary="Get spreadsheet metadata (spreadsheets.get)")
159
+ async def get_spreadsheet(
160
+ spreadsheet_id: str,
161
+ creds: SheetsCredentials = Depends(_credentials),
162
+ service: SheetsService = Depends(get_sheets_service),
163
+ ranges: Optional[str] = None,
164
+ include_grid_data: bool = False,
165
+ ):
166
+ start = time.perf_counter()
167
+ try:
168
+ data = await service.get_spreadsheet(
169
+ creds,
170
+ spreadsheet_id,
171
+ ranges=ranges.split(",") if ranges else None,
172
+ include_grid_data=include_grid_data,
173
+ )
174
+ except SheetsAPIError as exc:
175
+ raise _http_error(exc) from exc
176
+ _logger.info("Sheets spreadsheet %s fetched (%.2fms)", spreadsheet_id, _elapsed_ms(start))
177
+ return _ok(start, creds, data)
178
+
179
+
180
+ @router.get("/spreadsheets/{spreadsheet_id}/sheets/{sheet_id}",
181
+ response_model=SheetsGenericResponse,
182
+ summary="Get a single sheet's metadata (spreadsheets.sheets.get)")
183
+ async def get_sheet(
184
+ spreadsheet_id: str,
185
+ sheet_id: int,
186
+ creds: SheetsCredentials = Depends(_credentials),
187
+ service: SheetsService = Depends(get_sheets_service),
188
+ ranges: Optional[str] = None,
189
+ include_grid_data: bool = False,
190
+ ):
191
+ start = time.perf_counter()
192
+ try:
193
+ data = await service.get_sheet(
194
+ creds,
195
+ spreadsheet_id,
196
+ sheet_id,
197
+ ranges=ranges.split(",") if ranges else None,
198
+ include_grid_data=include_grid_data,
199
+ )
200
+ except SheetsAPIError as exc:
201
+ raise _http_error(exc) from exc
202
+ _logger.info(
203
+ "Sheets sheet %d of %s fetched (%.2fms)", sheet_id, spreadsheet_id, _elapsed_ms(start)
204
+ )
205
+ return _ok(start, creds, data)
206
+
207
+
208
+ @router.post("/spreadsheets/{spreadsheet_id}:batchUpdate",
209
+ response_model=SheetsGenericResponse,
210
+ summary="Apply batch mutations (addSheet, updateCells, etc.)")
211
+ async def batch_update(
212
+ spreadsheet_id: str,
213
+ body: SheetsBatchUpdateRequest,
214
+ creds: SheetsCredentials = Depends(_credentials),
215
+ service: SheetsService = Depends(get_sheets_service),
216
+ ):
217
+ start = time.perf_counter()
218
+ try:
219
+ data = await service.batch_update(
220
+ creds, spreadsheet_id, requests=body.requests
221
+ )
222
+ except SheetsAPIError as exc:
223
+ raise _http_error(exc) from exc
224
+ _logger.info("Sheets spreadsheet %s batch-updated (%.2fms)", spreadsheet_id, _elapsed_ms(start))
225
+ return _ok(start, creds, data)
226
+
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # Values
230
+ # ---------------------------------------------------------------------------
231
+
232
+ @router.get("/spreadsheets/{spreadsheet_id}/values/{range}",
233
+ response_model=SheetsGenericResponse,
234
+ summary="Read cell values from a range (values.get)")
235
+ async def get_values(
236
+ spreadsheet_id: str,
237
+ range: str,
238
+ creds: SheetsCredentials = Depends(_credentials),
239
+ service: SheetsService = Depends(get_sheets_service),
240
+ major_dimension: Optional[str] = None,
241
+ value_render_option: Optional[str] = None,
242
+ date_time_render_option: Optional[str] = None,
243
+ ):
244
+ start = time.perf_counter()
245
+ try:
246
+ data = await service.get_values(
247
+ creds,
248
+ spreadsheet_id,
249
+ range,
250
+ major_dimension=major_dimension,
251
+ value_render_option=value_render_option,
252
+ date_time_render_option=date_time_render_option,
253
+ )
254
+ except SheetsAPIError as exc:
255
+ raise _http_error(exc) from exc
256
+ _logger.info("Sheets values %s!%s fetched (%.2fms)", spreadsheet_id, range, _elapsed_ms(start))
257
+ return _ok(start, creds, data)
258
+
259
+
260
+ @router.get("/spreadsheets/{spreadsheet_id}/values:batchGet",
261
+ response_model=SheetsGenericResponse,
262
+ summary="Read multiple ranges in one call (values.batchGet)")
263
+ async def batch_get_values(
264
+ spreadsheet_id: str,
265
+ creds: SheetsCredentials = Depends(_credentials),
266
+ service: SheetsService = Depends(get_sheets_service),
267
+ ranges: Optional[str] = None,
268
+ major_dimension: Optional[str] = None,
269
+ value_render_option: Optional[str] = None,
270
+ date_time_render_option: Optional[str] = None,
271
+ ):
272
+ start = time.perf_counter()
273
+ if not ranges:
274
+ raise HTTPException(status_code=400, detail="At least one 'ranges' value is required.")
275
+ try:
276
+ data = await service.batch_get_values(
277
+ creds,
278
+ spreadsheet_id,
279
+ ranges=ranges.split(","),
280
+ major_dimension=major_dimension,
281
+ value_render_option=value_render_option,
282
+ date_time_render_option=date_time_render_option,
283
+ )
284
+ except SheetsAPIError as exc:
285
+ raise _http_error(exc) from exc
286
+ _logger.info("Sheets batchGet %s fetched (%.2fms)", spreadsheet_id, _elapsed_ms(start))
287
+ return _ok(start, creds, data)
288
+
289
+
290
+ @router.put("/spreadsheets/{spreadsheet_id}/values/{range}",
291
+ response_model=SheetsGenericResponse,
292
+ summary="Overwrite cell values in a range (values.update)")
293
+ async def update_values(
294
+ spreadsheet_id: str,
295
+ range: str,
296
+ body: SheetsValueUpdateRequest,
297
+ creds: SheetsCredentials = Depends(_credentials),
298
+ service: SheetsService = Depends(get_sheets_service),
299
+ value_input_option: str = "RAW",
300
+ ):
301
+ start = time.perf_counter()
302
+ try:
303
+ data = await service.update_values(
304
+ creds,
305
+ spreadsheet_id,
306
+ range,
307
+ values=body.values,
308
+ major_dimension=body.major_dimension,
309
+ value_input_option=value_input_option,
310
+ include_values_in_response=body.include_values_in_response,
311
+ value_render_option=body.value_render_option,
312
+ )
313
+ except SheetsAPIError as exc:
314
+ raise _http_error(exc) from exc
315
+ _logger.info("Sheets values %s!%s updated (%.2fms)", spreadsheet_id, range, _elapsed_ms(start))
316
+ return _ok(start, creds, data)
317
+
318
+
319
+ @router.post("/spreadsheets/{spreadsheet_id}/values/{range}:append",
320
+ response_model=SheetsGenericResponse,
321
+ summary="Append rows to a range (values.append)")
322
+ async def append_values(
323
+ spreadsheet_id: str,
324
+ range: str,
325
+ body: SheetsAppendRequest,
326
+ creds: SheetsCredentials = Depends(_credentials),
327
+ service: SheetsService = Depends(get_sheets_service),
328
+ value_input_option: str = "USER_ENTERED",
329
+ insert_data_option: Optional[str] = None,
330
+ ):
331
+ start = time.perf_counter()
332
+ try:
333
+ data = await service.append_values(
334
+ creds,
335
+ spreadsheet_id,
336
+ range,
337
+ values=body.values,
338
+ major_dimension=body.major_dimension,
339
+ value_input_option=value_input_option,
340
+ insert_data_option=insert_data_option,
341
+ include_values_in_response=False,
342
+ )
343
+ except SheetsAPIError as exc:
344
+ raise _http_error(exc) from exc
345
+ _logger.info("Sheets values %s!%s appended (%.2fms)", spreadsheet_id, range, _elapsed_ms(start))
346
+ return _ok(start, creds, data)
347
+
348
+
349
+ @router.post("/spreadsheets/{spreadsheet_id}/values/{range}:clear",
350
+ response_model=SheetsGenericResponse,
351
+ summary="Clear cell values in a range (values.clear)")
352
+ async def clear_values(
353
+ spreadsheet_id: str,
354
+ range: str,
355
+ creds: SheetsCredentials = Depends(_credentials),
356
+ service: SheetsService = Depends(get_sheets_service),
357
+ ):
358
+ start = time.perf_counter()
359
+ try:
360
+ data = await service.clear_values(creds, spreadsheet_id, range)
361
+ except SheetsAPIError as exc:
362
+ raise _http_error(exc) from exc
363
+ _logger.info("Sheets values %s!%s cleared (%.2fms)", spreadsheet_id, range, _elapsed_ms(start))
364
+ return _ok(start, creds, data)
app/config.py CHANGED
@@ -141,6 +141,16 @@ class Settings(BaseSettings):
141
  gmail_default_scope: str = "https://www.googleapis.com/auth/gmail.modify"
142
  gmail_refresh_buffer_seconds: int = 60
143
 
 
 
 
 
 
 
 
 
 
 
144
  # Media-to-Media conversion settings
145
  media_output_dir: str = "./data/media-convert"
146
  media_max_workers: int = 4
 
141
  gmail_default_scope: str = "https://www.googleapis.com/auth/gmail.modify"
142
  gmail_refresh_buffer_seconds: int = 60
143
 
144
+ # Google Sheets API settings
145
+ sheets_api_base_url: str = "https://sheets.googleapis.com/v4"
146
+ sheets_timeout: float = 30.0
147
+ sheets_max_retries: int = 3
148
+ sheets_base_backoff_seconds: float = 1.0
149
+ sheets_max_backoff_seconds: float = 32.0
150
+ sheets_retryable_statuses: str = "429,500,502,503,504"
151
+ sheets_default_scope: str = "https://www.googleapis.com/auth/spreadsheets"
152
+ sheets_refresh_buffer_seconds: int = 60
153
+
154
  # Media-to-Media conversion settings
155
  media_output_dir: str = "./data/media-convert"
156
  media_max_workers: int = 4
app/models/schemas.py CHANGED
@@ -1996,3 +1996,79 @@ class GmailRefreshResponse(BaseModel):
1996
  token_type: Optional[str] = None
1997
  scope: Optional[str] = None
1998
  error: Optional[str] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1996
  token_type: Optional[str] = None
1997
  scope: Optional[str] = None
1998
  error: Optional[str] = None
1999
+
2000
+
2001
+ # ---------------------------------------------------------------------------
2002
+ # Google Sheets API (stateless, client-supplied OAuth credentials)
2003
+ # ---------------------------------------------------------------------------
2004
+
2005
+ class SheetsScope(Enum):
2006
+ """All Sheets-specific OAuth scopes (developers.google.com/sheets/api/auth).
2007
+
2008
+ Drive scopes are intentionally excluded here — they belong to the separate
2009
+ Google Drive API integration.
2010
+ """
2011
+
2012
+ READONLY = "https://www.googleapis.com/auth/spreadsheets.readonly"
2013
+ SPREADSHEETS = "https://www.googleapis.com/auth/spreadsheets"
2014
+
2015
+ @property
2016
+ def permission_level(self) -> str:
2017
+ return "sensitive"
2018
+
2019
+
2020
+ class SheetsCreateSpreadsheetRequest(BaseModel):
2021
+ """Body for creating a spreadsheet (spreadsheets.create)."""
2022
+
2023
+ title: str = Field(..., min_length=1, max_length=255, description="Spreadsheet title")
2024
+ sheets: Optional[List[Dict[str, Any]]] = Field(
2025
+ None,
2026
+ description="Initial sheet definitions, e.g. [{'properties': {'title': 'Data', 'index': 0}}]",
2027
+ )
2028
+ properties: Optional[Dict[str, Any]] = Field(
2029
+ None,
2030
+ description="Spreadsheet properties (locale, autoRecalc, defaultFormat, etc.)",
2031
+ )
2032
+
2033
+
2034
+ class SheetsValueUpdateRequest(BaseModel):
2035
+ """Body for overwriting a range of cells (values.update)."""
2036
+
2037
+ values: List[List[Any]] = Field(..., min_length=1, description="2D array of cell values (rows of columns)")
2038
+ major_dimension: Optional[str] = Field(None, pattern="^(ROWS|COLUMNS)$", description="Major dimension of the values array")
2039
+ include_values_in_response: Optional[bool] = Field(None, description="Whether the response should include updated values")
2040
+ value_render_option: Optional[str] = Field(None, pattern="^(FORMATTED_VALUE|UNFORMATTED_VALUE|FORMULA)$", description="Render option for returned values")
2041
+
2042
+
2043
+ class SheetsAppendRequest(BaseModel):
2044
+ """Body for appending rows to a range (values.append)."""
2045
+
2046
+ values: List[List[Any]] = Field(..., min_length=1, description="2D array of cell values to append")
2047
+ major_dimension: Optional[str] = Field(None, pattern="^(ROWS|COLUMNS)$", description="Major dimension of the values array")
2048
+
2049
+
2050
+ class SheetsBatchUpdateRequest(BaseModel):
2051
+ """Body for batch mutation requests (spreadsheets.batchUpdate)."""
2052
+
2053
+ requests: List[Dict[str, Any]] = Field(..., min_length=1, description="List of request objects (addSheet, updateCells, etc.)")
2054
+
2055
+
2056
+ class SheetsGenericResponse(BaseModel):
2057
+ """Standard Google Sheets operation envelope."""
2058
+
2059
+ success: bool
2060
+ time_ms: float = 0.0
2061
+ data: Optional[Any] = Field(None, description="Raw Sheets API payload")
2062
+ refreshed_access_token: Optional[str] = Field(None, description="New access token when an automatic refresh occurred; persist it client-side")
2063
+ error: Optional[str] = Field(None, description="Human-readable error when success is false")
2064
+
2065
+
2066
+ class SheetsRefreshResponse(BaseModel):
2067
+ """Stateless token refresh result returned to the client for its own storage."""
2068
+
2069
+ success: bool
2070
+ access_token: Optional[str] = None
2071
+ expires_in: Optional[int] = None
2072
+ token_type: Optional[str] = None
2073
+ scope: Optional[str] = None
2074
+ error: Optional[str] = None
app/services/sheets_service.py ADDED
@@ -0,0 +1,563 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import random
5
+ import time
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ import httpx
10
+
11
+ from app.config import get_settings
12
+ from app.core.logger import get_logger
13
+ from app.utils.http_utils import SharedAsyncClient
14
+
15
+ _logger = get_logger(__name__)
16
+ _settings = get_settings()
17
+
18
+ _RETRYABLE_STATUS = frozenset({
19
+ int(code)
20
+ for code in _settings.sheets_retryable_statuses.split(",")
21
+ if code.strip().isdigit()
22
+ })
23
+
24
+
25
+ class SheetsAPIError(Exception):
26
+ """Raised for upstream Google Sheets API failures that map to a client-facing error."""
27
+
28
+ def __init__(self, message: str, status_code: int = 400, *, reason: str = "") -> None:
29
+ super().__init__(message)
30
+ self.message = message
31
+ self.status_code = status_code
32
+ self.reason = reason
33
+
34
+
35
+ @dataclass
36
+ class SheetsCredentials:
37
+ """Client-supplied OAuth credentials (never persisted by this backend).
38
+
39
+ ``refreshed_access_token`` is populated whenever the service transparently
40
+ refreshes the access token, so the stateless client can persist it itself.
41
+ """
42
+
43
+ access_token: str
44
+ refresh_token: Optional[str] = None
45
+ client_id: Optional[str] = None
46
+ client_secret: Optional[str] = None
47
+ expires_at: Optional[float] = None
48
+ refreshed_access_token: Optional[str] = field(default=None, repr=False)
49
+
50
+ @property
51
+ def can_refresh(self) -> bool:
52
+ return bool(self.refresh_token and self.client_id and self.client_secret)
53
+
54
+ @property
55
+ def needs_proactive_refresh(self) -> bool:
56
+ if self.expires_at is None:
57
+ return False
58
+ return time.time() >= self.expires_at - _settings.sheets_refresh_buffer_seconds
59
+
60
+
61
+ class SheetsService:
62
+ """Stateless, fully-async abstraction over the Google Sheets REST API.
63
+
64
+ Credentials are supplied by the caller on every invocation. Access tokens
65
+ are transparently refreshed against Google's token endpoint when a
66
+ ``refresh_token`` + ``client_id`` + ``client_secret`` are provided, and the
67
+ resulting token is surfaced via ``SheetsCredentials.refreshed_access_token``.
68
+ """
69
+
70
+ def __init__(self) -> None:
71
+ self._http = SharedAsyncClient(
72
+ timeout=httpx.Timeout(_settings.sheets_timeout),
73
+ follow_redirects=False,
74
+ )
75
+
76
+ async def _get_client(self) -> httpx.AsyncClient:
77
+ return await self._http.get()
78
+
79
+ async def close(self) -> None:
80
+ await self._http.close()
81
+
82
+ # ------------------------------------------------------------------
83
+ # Core request pipeline: auth, retry, backoff, error mapping
84
+ # ------------------------------------------------------------------
85
+
86
+ async def _request(
87
+ self,
88
+ creds: SheetsCredentials,
89
+ method: str,
90
+ path: str,
91
+ *,
92
+ params: Optional[Dict[str, Any]] = None,
93
+ json_body: Optional[Any] = None,
94
+ ) -> Any:
95
+ """Send a Sheets API request with proactive + on-401 refresh and retries."""
96
+ if creds.needs_proactive_refresh:
97
+ await self._refresh(creds, context=f"{method} {path}")
98
+
99
+ refresh_done = False
100
+ attempt = 0
101
+ while True:
102
+ response = await self._send(
103
+ creds, method, path, params=params, json_body=json_body
104
+ )
105
+ if 200 <= response.status_code < 300:
106
+ return self._decode(response)
107
+
108
+ if response.status_code == 401 and creds.can_refresh and not refresh_done:
109
+ _logger.warning(
110
+ "Sheets 401 for %s %s; refreshing access token once",
111
+ method,
112
+ path,
113
+ )
114
+ await self._refresh(creds, context=f"{method} {path}")
115
+ refresh_done = True
116
+ continue
117
+
118
+ if response.status_code in _RETRYABLE_STATUS and attempt < _settings.sheets_max_retries:
119
+ await self._backoff(response, attempt)
120
+ attempt += 1
121
+ continue
122
+
123
+ raise self._map_error(response, creds)
124
+
125
+ async def _send(
126
+ self,
127
+ creds: SheetsCredentials,
128
+ method: str,
129
+ path: str,
130
+ *,
131
+ params: Optional[Dict[str, Any]] = None,
132
+ json_body: Optional[Any] = None,
133
+ ) -> httpx.Response:
134
+ client = await self._get_client()
135
+ url = f"{_settings.sheets_api_base_url}{path}"
136
+ headers = {
137
+ "Authorization": f"Bearer {creds.access_token}",
138
+ "Accept": "application/json",
139
+ }
140
+ try:
141
+ return await client.request(
142
+ method,
143
+ url,
144
+ params=params,
145
+ json=json_body,
146
+ headers=headers,
147
+ )
148
+ except httpx.TimeoutException as exc:
149
+ raise SheetsAPIError(
150
+ f"Sheets API request timed out for {method} {path}.", status_code=504
151
+ ) from exc
152
+ except httpx.RequestError as exc:
153
+ raise SheetsAPIError(
154
+ f"Failed to reach Sheets API: {exc}", status_code=502
155
+ ) from exc
156
+
157
+ @staticmethod
158
+ def _decode(response: httpx.Response) -> Any:
159
+ if not response.content:
160
+ return None
161
+ try:
162
+ return response.json()
163
+ except ValueError:
164
+ return response.text
165
+
166
+ async def _backoff(self, response: httpx.Response, attempt: int) -> None:
167
+ retry_after = response.headers.get("Retry-After")
168
+ delay: Optional[float] = None
169
+ if retry_after:
170
+ try:
171
+ delay = float(retry_after)
172
+ except (TypeError, ValueError):
173
+ delay = None
174
+ if delay is None:
175
+ base = _settings.sheets_base_backoff_seconds * (2 ** attempt)
176
+ delay = min(base, _settings.sheets_max_backoff_seconds) * (0.5 + random.random())
177
+ delay = min(delay, _settings.sheets_max_backoff_seconds + 5.0)
178
+ _logger.warning(
179
+ "Sheets transient HTTP %s; retrying in %.2fs (attempt %d/%d)",
180
+ response.status_code,
181
+ delay,
182
+ attempt + 1,
183
+ _settings.sheets_max_retries,
184
+ )
185
+ await asyncio.sleep(delay)
186
+
187
+ def _map_error(self, response: httpx.Response, creds: SheetsCredentials) -> SheetsAPIError:
188
+ status = response.status_code
189
+ reason = ""
190
+ detail = ""
191
+ try:
192
+ body = response.json()
193
+ error = body.get("error") or {}
194
+ if isinstance(error, dict):
195
+ detail = error.get("message", "")
196
+ errors = error.get("errors") or []
197
+ if errors and isinstance(errors[0], dict):
198
+ reason = errors[0].get("reason", "")
199
+ except Exception:
200
+ body = {}
201
+ detail = response.text[:500]
202
+
203
+ _logger.error(
204
+ "Sheets API error: HTTP %s reason=%s detail=%s", status, reason, detail
205
+ )
206
+
207
+ if status == 401:
208
+ if creds.can_refresh:
209
+ return SheetsAPIError(
210
+ "Access token is invalid or expired and could not be refreshed. "
211
+ "Re-authorize via POST /api/v1/google/oauth/auth-url.",
212
+ status_code=401,
213
+ reason=reason,
214
+ )
215
+ return SheetsAPIError(
216
+ "Access token is invalid or expired. Refresh it via "
217
+ "POST /api/v1/google/oauth/refresh (or /api/v1/google/sheets/token/refresh) "
218
+ "and retry, or supply a fresh access token.",
219
+ status_code=401,
220
+ reason=reason,
221
+ )
222
+ if status == 403 and reason == "PERMISSION_DENIED":
223
+ return SheetsAPIError(
224
+ "Permission denied. The access token may be missing the required "
225
+ "Sheets scope (see /api/v1/google/sheets/scopes), or the spreadsheet "
226
+ "is not shared with the authenticated account.",
227
+ status_code=403,
228
+ reason=reason,
229
+ )
230
+ if status == 404:
231
+ return SheetsAPIError(
232
+ detail or "Spreadsheet or range not found.",
233
+ status_code=404,
234
+ reason=reason,
235
+ )
236
+ if status == 429:
237
+ return SheetsAPIError(
238
+ "Sheets API rate limit exceeded. Please retry after a short delay.",
239
+ status_code=429,
240
+ reason=reason,
241
+ )
242
+ if status in _RETRYABLE_STATUS:
243
+ return SheetsAPIError(
244
+ detail or f"Sheets API service error (HTTP {status}).",
245
+ status_code=status,
246
+ reason=reason,
247
+ )
248
+ return SheetsAPIError(
249
+ detail or f"Sheets API error (HTTP {status}).",
250
+ status_code=status,
251
+ reason=reason,
252
+ )
253
+
254
+ # ------------------------------------------------------------------
255
+ # Token lifecycle
256
+ # ------------------------------------------------------------------
257
+
258
+ async def refresh_access_token(
259
+ self,
260
+ *,
261
+ client_id: str,
262
+ client_secret: str,
263
+ refresh_token: str,
264
+ ) -> Dict[str, Any]:
265
+ """Refresh an access token without touching any spreadsheet (standalone)."""
266
+ tokens = await self._refresh(
267
+ creds=None,
268
+ client_id=client_id,
269
+ client_secret=client_secret,
270
+ refresh_token=refresh_token,
271
+ context="standalone refresh",
272
+ )
273
+ return tokens
274
+
275
+ async def _refresh(
276
+ self,
277
+ creds: Optional[SheetsCredentials],
278
+ *,
279
+ context: str,
280
+ client_id: Optional[str] = None,
281
+ client_secret: Optional[str] = None,
282
+ refresh_token: Optional[str] = None,
283
+ ) -> Dict[str, Any]:
284
+ if creds is not None:
285
+ client_id = creds.client_id
286
+ client_secret = creds.client_secret
287
+ refresh_token = creds.refresh_token
288
+
289
+ if not (client_id and client_secret and refresh_token):
290
+ raise SheetsAPIError(
291
+ "Token refresh requires refresh_token, client_id and client_secret.",
292
+ status_code=400,
293
+ )
294
+
295
+ client = await self._get_client()
296
+ data = {
297
+ "client_id": client_id,
298
+ "client_secret": client_secret,
299
+ "refresh_token": refresh_token,
300
+ "grant_type": "refresh_token",
301
+ }
302
+ try:
303
+ response = await client.post(_settings.google_oauth_token_url, data=data)
304
+ except httpx.TimeoutException as exc:
305
+ raise SheetsAPIError(
306
+ "Google token refresh timed out.", status_code=504
307
+ ) from exc
308
+ except httpx.RequestError as exc:
309
+ raise SheetsAPIError(
310
+ f"Failed to reach Google token endpoint: {exc}", status_code=502
311
+ ) from exc
312
+
313
+ if response.status_code == 200:
314
+ tokens = response.json()
315
+ new_token = tokens.get("access_token")
316
+ if not new_token:
317
+ raise SheetsAPIError(
318
+ "Google did not return an access token.", status_code=502
319
+ )
320
+ expires_in = int(tokens.get("expires_in", 3600))
321
+ if creds is not None:
322
+ creds.access_token = new_token
323
+ creds.expires_at = time.time() + expires_in
324
+ creds.refreshed_access_token = new_token
325
+ _logger.info("Sheets access token refreshed (%s)", context)
326
+ return tokens
327
+
328
+ try:
329
+ body = response.json()
330
+ error = body.get("error", "")
331
+ description = body.get("error_description", "")
332
+ except Exception:
333
+ error = ""
334
+ description = ""
335
+ body = {}
336
+
337
+ if error == "invalid_grant":
338
+ raise SheetsAPIError(
339
+ description or "Refresh token is invalid, expired, or revoked. "
340
+ "Re-authorize via POST /api/v1/google/oauth/auth-url.",
341
+ status_code=401,
342
+ reason=error,
343
+ )
344
+ if error == "invalid_client":
345
+ raise SheetsAPIError(
346
+ description or "Invalid client_id or client_secret.",
347
+ status_code=401,
348
+ reason=error,
349
+ )
350
+ raise SheetsAPIError(
351
+ description or f"Google token refresh failed (HTTP {response.status_code}).",
352
+ status_code=response.status_code if response.status_code >= 400 else 502,
353
+ reason=error,
354
+ )
355
+
356
+ # ------------------------------------------------------------------
357
+ # Scopes
358
+ # ------------------------------------------------------------------
359
+
360
+ async def list_available_scopes(self) -> List[Dict[str, Any]]:
361
+ from app.models.schemas import SheetsScope
362
+
363
+ return [
364
+ {
365
+ "name": scope.name,
366
+ "uri": scope.value,
367
+ "permission_level": scope.permission_level,
368
+ }
369
+ for scope in SheetsScope
370
+ ]
371
+
372
+ # ------------------------------------------------------------------
373
+ # Spreadsheets
374
+ # ------------------------------------------------------------------
375
+
376
+ async def create_spreadsheet(
377
+ self,
378
+ creds: SheetsCredentials,
379
+ *,
380
+ title: str,
381
+ sheets: Optional[List[Dict[str, Any]]] = None,
382
+ properties: Optional[Dict[str, Any]] = None,
383
+ ) -> Dict[str, Any]:
384
+ body: Dict[str, Any] = {"properties": {"title": title}}
385
+ if properties:
386
+ body["properties"].update(properties)
387
+ if sheets:
388
+ body["sheets"] = sheets
389
+ return await self._request(creds, "POST", "/spreadsheets", json_body=body)
390
+
391
+ async def get_spreadsheet(
392
+ self,
393
+ creds: SheetsCredentials,
394
+ spreadsheet_id: str,
395
+ *,
396
+ ranges: Optional[List[str]] = None,
397
+ include_grid_data: bool = False,
398
+ ) -> Dict[str, Any]:
399
+ params: Dict[str, Any] = {}
400
+ if ranges:
401
+ params["ranges"] = ranges
402
+ if include_grid_data:
403
+ params["includeGridData"] = "true"
404
+ return await self._request(
405
+ creds, "GET", f"/spreadsheets/{spreadsheet_id}", params=params or None
406
+ )
407
+
408
+ async def get_sheet(
409
+ self,
410
+ creds: SheetsCredentials,
411
+ spreadsheet_id: str,
412
+ sheet_id: int,
413
+ *,
414
+ ranges: Optional[List[str]] = None,
415
+ include_grid_data: bool = False,
416
+ ) -> Dict[str, Any]:
417
+ params: Dict[str, Any] = {}
418
+ if ranges:
419
+ params["ranges"] = ranges
420
+ if include_grid_data:
421
+ params["includeGridData"] = "true"
422
+ return await self._request(
423
+ creds,
424
+ "GET",
425
+ f"/spreadsheets/{spreadsheet_id}/sheets/{sheet_id}",
426
+ params=params or None,
427
+ )
428
+
429
+ async def batch_update(
430
+ self,
431
+ creds: SheetsCredentials,
432
+ spreadsheet_id: str,
433
+ *,
434
+ requests: List[Dict[str, Any]],
435
+ response_include_grid_data: Optional[bool] = None,
436
+ ) -> Dict[str, Any]:
437
+ body: Dict[str, Any] = {"requests": requests}
438
+ if response_include_grid_data is not None:
439
+ body["responseIncludeGridData"] = response_include_grid_data
440
+ return await self._request(
441
+ creds, "POST", f"/spreadsheets/{spreadsheet_id}:batchUpdate", json_body=body
442
+ )
443
+
444
+ # ------------------------------------------------------------------
445
+ # Values
446
+ # ------------------------------------------------------------------
447
+
448
+ async def get_values(
449
+ self,
450
+ creds: SheetsCredentials,
451
+ spreadsheet_id: str,
452
+ range_: str,
453
+ *,
454
+ major_dimension: Optional[str] = None,
455
+ value_render_option: Optional[str] = None,
456
+ date_time_render_option: Optional[str] = None,
457
+ ) -> Dict[str, Any]:
458
+ params: Dict[str, Any] = {}
459
+ if major_dimension:
460
+ params["majorDimension"] = major_dimension
461
+ if value_render_option:
462
+ params["valueRenderOption"] = value_render_option
463
+ if date_time_render_option:
464
+ params["dateTimeRenderOption"] = date_time_render_option
465
+ return await self._request(
466
+ creds,
467
+ "GET",
468
+ f"/spreadsheets/{spreadsheet_id}/values/{range_}",
469
+ params=params or None,
470
+ )
471
+
472
+ async def batch_get_values(
473
+ self,
474
+ creds: SheetsCredentials,
475
+ spreadsheet_id: str,
476
+ *,
477
+ ranges: List[str],
478
+ major_dimension: Optional[str] = None,
479
+ value_render_option: Optional[str] = None,
480
+ date_time_render_option: Optional[str] = None,
481
+ ) -> Dict[str, Any]:
482
+ params: Dict[str, Any] = {"ranges": ranges}
483
+ if major_dimension:
484
+ params["majorDimension"] = major_dimension
485
+ if value_render_option:
486
+ params["valueRenderOption"] = value_render_option
487
+ if date_time_render_option:
488
+ params["dateTimeRenderOption"] = date_time_render_option
489
+ return await self._request(
490
+ creds,
491
+ "GET",
492
+ f"/spreadsheets/{spreadsheet_id}/values:batchGet",
493
+ params=params,
494
+ )
495
+
496
+ async def update_values(
497
+ self,
498
+ creds: SheetsCredentials,
499
+ spreadsheet_id: str,
500
+ range_: str,
501
+ *,
502
+ values: List[List[Any]],
503
+ major_dimension: Optional[str] = None,
504
+ value_input_option: str = "RAW",
505
+ include_values_in_response: Optional[bool] = None,
506
+ value_render_option: Optional[str] = None,
507
+ ) -> Dict[str, Any]:
508
+ body: Dict[str, Any] = {"values": values}
509
+ if major_dimension:
510
+ body["majorDimension"] = major_dimension
511
+ params: Dict[str, Any] = {"valueInputOption": value_input_option}
512
+ if include_values_in_response is not None:
513
+ params["includeValuesInResponse"] = "true" if include_values_in_response else "false"
514
+ if value_render_option:
515
+ params["valueRenderOption"] = value_render_option
516
+ return await self._request(
517
+ creds,
518
+ "PUT",
519
+ f"/spreadsheets/{spreadsheet_id}/values/{range_}",
520
+ params=params,
521
+ json_body=body,
522
+ )
523
+
524
+ async def append_values(
525
+ self,
526
+ creds: SheetsCredentials,
527
+ spreadsheet_id: str,
528
+ range_: str,
529
+ *,
530
+ values: List[List[Any]],
531
+ major_dimension: Optional[str] = None,
532
+ value_input_option: str = "USER_ENTERED",
533
+ insert_data_option: Optional[str] = None,
534
+ include_values_in_response: Optional[bool] = None,
535
+ ) -> Dict[str, Any]:
536
+ body: Dict[str, Any] = {"values": values}
537
+ if major_dimension:
538
+ body["majorDimension"] = major_dimension
539
+ params: Dict[str, Any] = {"valueInputOption": value_input_option}
540
+ if insert_data_option:
541
+ params["insertDataOption"] = insert_data_option
542
+ if include_values_in_response is not None:
543
+ params["includeValuesInResponse"] = "true" if include_values_in_response else "false"
544
+ return await self._request(
545
+ creds,
546
+ "POST",
547
+ f"/spreadsheets/{spreadsheet_id}/values/{range_}:append",
548
+ params=params,
549
+ json_body=body,
550
+ )
551
+
552
+ async def clear_values(
553
+ self,
554
+ creds: SheetsCredentials,
555
+ spreadsheet_id: str,
556
+ range_: str,
557
+ ) -> Dict[str, Any]:
558
+ return await self._request(
559
+ creds,
560
+ "POST",
561
+ f"/spreadsheets/{spreadsheet_id}/values/{range_}:clear",
562
+ json_body={},
563
+ )