|
9 | 9 |
|
10 | 10 | import json |
11 | 11 | import logging |
| 12 | +from datetime import timezone |
12 | 13 | from typing import Any, Callable, Awaitable, Optional |
13 | 14 | from functools import wraps |
14 | 15 |
|
|
105 | 106 | generate_resource_id, |
106 | 107 | etags_match, |
107 | 108 | REPLICA_NOW_RFC3339, |
| 109 | + parse_rfc3339, |
108 | 110 | ) |
109 | 111 |
|
110 | 112 |
|
| 113 | +# ============================================================================ |
| 114 | +# WATCH EXPIRATION PARSING |
| 115 | +# ============================================================================ |
| 116 | + |
| 117 | + |
| 118 | +def parse_watch_expiration(expiration: Any) -> Optional[int]: |
| 119 | + """ |
| 120 | + Parse expiration value for watch channels. |
| 121 | +
|
| 122 | + Google Calendar API accepts expiration as milliseconds since Unix epoch. |
| 123 | + Some clients may send ISO date strings which need to be converted. |
| 124 | +
|
| 125 | + Args: |
| 126 | + expiration: The expiration value (int, string int, or ISO date string) |
| 127 | +
|
| 128 | + Returns: |
| 129 | + Expiration in milliseconds since epoch, or None if not provided |
| 130 | +
|
| 131 | + Raises: |
| 132 | + ValidationError: If the expiration format is invalid |
| 133 | + """ |
| 134 | + if expiration is None: |
| 135 | + return None |
| 136 | + |
| 137 | + # If already an int, return as-is |
| 138 | + if isinstance(expiration, int): |
| 139 | + return expiration |
| 140 | + |
| 141 | + # If it's a string, try to parse it |
| 142 | + if isinstance(expiration, str): |
| 143 | + expiration = expiration.strip() |
| 144 | + if not expiration: |
| 145 | + return None |
| 146 | + |
| 147 | + # Try parsing as integer (milliseconds) |
| 148 | + try: |
| 149 | + return int(expiration) |
| 150 | + except ValueError: |
| 151 | + pass |
| 152 | + |
| 153 | + # Try parsing as ISO date string |
| 154 | + try: |
| 155 | + dt = parse_rfc3339(expiration) |
| 156 | + # If datetime is naive, assume UTC |
| 157 | + if dt.tzinfo is None: |
| 158 | + dt = dt.replace(tzinfo=timezone.utc) |
| 159 | + # Convert to milliseconds since epoch |
| 160 | + return int(dt.timestamp() * 1000) |
| 161 | + except (ValueError, AttributeError): |
| 162 | + pass |
| 163 | + |
| 164 | + # Invalid format |
| 165 | + raise ValidationError( |
| 166 | + f"Invalid expiration format: {expiration}. " |
| 167 | + "Expected milliseconds since epoch or ISO 8601 date string." |
| 168 | + ) |
| 169 | + |
| 170 | + raise ValidationError( |
| 171 | + f"Invalid expiration type: {type(expiration).__name__}. " |
| 172 | + "Expected integer or string." |
| 173 | + ) |
| 174 | + |
| 175 | + |
111 | 176 | # ============================================================================ |
112 | 177 | # REQUEST UTILITIES |
113 | 178 | # ============================================================================ |
@@ -1036,15 +1101,15 @@ async def calendar_list_watch(request: Request) -> JSONResponse: |
1036 | 1101 | from ..database.schema import Channel |
1037 | 1102 |
|
1038 | 1103 | resource_id = generate_resource_id() |
1039 | | - expiration = body.get("expiration") |
| 1104 | + expiration_ms = parse_watch_expiration(body.get("expiration")) |
1040 | 1105 |
|
1041 | 1106 | channel = Channel( |
1042 | 1107 | id=channel_id, |
1043 | 1108 | resource_id=resource_id, |
1044 | 1109 | resource_uri=f"/users/me/calendarList", |
1045 | 1110 | type=channel_type, |
1046 | 1111 | address=address, |
1047 | | - expiration=int(expiration) if expiration else None, |
| 1112 | + expiration=expiration_ms, |
1048 | 1113 | token=body.get("token"), |
1049 | 1114 | params=body.get("params"), |
1050 | 1115 | payload=body.get("payload", False), |
@@ -1961,15 +2026,15 @@ async def events_watch(request: Request) -> JSONResponse: |
1961 | 2026 | from ..database.schema import Channel |
1962 | 2027 |
|
1963 | 2028 | resource_id = generate_resource_id() |
1964 | | - expiration = body.get("expiration") |
| 2029 | + expiration_ms = parse_watch_expiration(body.get("expiration")) |
1965 | 2030 |
|
1966 | 2031 | channel = Channel( |
1967 | 2032 | id=channel_id, |
1968 | 2033 | resource_id=resource_id, |
1969 | 2034 | resource_uri=f"/calendars/{calendar_id}/events", |
1970 | 2035 | type=channel_type, |
1971 | 2036 | address=address, |
1972 | | - expiration=int(expiration) if expiration else None, |
| 2037 | + expiration=expiration_ms, |
1973 | 2038 | token=body.get("token"), |
1974 | 2039 | params=body.get("params"), |
1975 | 2040 | payload=body.get("payload", False), |
@@ -2400,15 +2465,15 @@ async def acl_watch(request: Request) -> JSONResponse: |
2400 | 2465 | from ..database.schema import Channel |
2401 | 2466 |
|
2402 | 2467 | resource_id = generate_resource_id() |
2403 | | - expiration = body.get("expiration") |
| 2468 | + expiration_ms = parse_watch_expiration(body.get("expiration")) |
2404 | 2469 |
|
2405 | 2470 | channel = Channel( |
2406 | 2471 | id=channel_id, |
2407 | 2472 | resource_id=resource_id, |
2408 | 2473 | resource_uri=f"/calendars/{calendar_id}/acl", |
2409 | 2474 | type=channel_type, |
2410 | 2475 | address=address, |
2411 | | - expiration=int(expiration) if expiration else None, |
| 2476 | + expiration=expiration_ms, |
2412 | 2477 | token=body.get("token"), |
2413 | 2478 | user_id=user_id, # Track ownership |
2414 | 2479 | params=body.get("params"), |
@@ -2681,15 +2746,15 @@ async def settings_watch(request: Request) -> JSONResponse: |
2681 | 2746 | from ..database.schema import Channel |
2682 | 2747 |
|
2683 | 2748 | resource_id = generate_resource_id() |
2684 | | - expiration = body.get("expiration") |
| 2749 | + expiration_ms = parse_watch_expiration(body.get("expiration")) |
2685 | 2750 |
|
2686 | 2751 | channel = Channel( |
2687 | 2752 | id=channel_id, |
2688 | 2753 | resource_id=resource_id, |
2689 | 2754 | resource_uri=f"/users/{user_id}/settings", |
2690 | 2755 | type=channel_type, |
2691 | 2756 | address=address, |
2692 | | - expiration=int(expiration) if expiration else None, |
| 2757 | + expiration=expiration_ms, |
2693 | 2758 | token=body.get("token"), |
2694 | 2759 | params=body.get("params"), |
2695 | 2760 | payload=body.get("payload", False), |
|
0 commit comments