graphplug 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
graphplug/_request.py ADDED
@@ -0,0 +1,135 @@
1
+ """Building requests and shaping responses.
2
+
3
+ Ported from the C# core's `GraphUrlBuilder`, `ResponseHeaderFilter` and `GraphOperation`.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from typing import Any, Dict, Mapping, Optional
10
+ from urllib.parse import quote, urlencode
11
+
12
+ from ._errors import GraphError
13
+
14
+ __all__ = ["build_url", "odata", "segment", "allowlisted", "reject_authorization", "DEFAULT_VERSION"]
15
+
16
+ BASE_URL = "https://graph.microsoft.com"
17
+ DEFAULT_VERSION = "v1.0"
18
+ SUPPORTED_VERSIONS = (DEFAULT_VERSION, "beta")
19
+
20
+ #: What tells a whole URL apart from a Graph path: a scheme and ``://`` at the very start.
21
+ #:
22
+ #: Deliberately not ``urlsplit(path).scheme``, which reads "C:\\secrets" as scheme "c". Anchored at
23
+ #: the start because a Graph path may carry ``://`` further in -- a drive search for a URL, say.
24
+ #: The equivalent check in the C# core was the fix for a bug that broke every request on Linux
25
+ #: while passing on Windows.
26
+ _ABSOLUTE = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*://")
27
+
28
+ #: Left alone when an id or a drive path goes into a URL path. Everything else -- ``#``, ``?``,
29
+ #: ``%``, spaces -- is percent-encoded, or it would end the path early.
30
+ _PATH_SAFE = "/:@=+$,!"
31
+
32
+ #: OData parameters, spelled as Python keywords.
33
+ ODATA_KEYWORDS = ("select", "filter", "top", "skip", "expand", "orderby", "search", "count")
34
+
35
+ #: Response headers reach the caller through an allowlist, never a denylist: a denylist fails open
36
+ #: on whatever header Microsoft adds tomorrow. Authorization and WWW-Authenticate therefore cannot
37
+ #: escape even by accident.
38
+ _HEADER_ALLOWLIST = (
39
+ "request-id",
40
+ "client-request-id",
41
+ "Date",
42
+ "Retry-After",
43
+ "Content-Type",
44
+ "Location",
45
+ "ETag",
46
+ )
47
+
48
+
49
+ def odata(options: Mapping[str, Any]) -> Dict[str, Any]:
50
+ """Turn ``select=...`` into ``$select``, leaving anything else a literal parameter."""
51
+ query: Dict[str, Any] = {}
52
+ for name, value in options.items():
53
+ if value is None:
54
+ continue
55
+ query[f"${name}" if name in ODATA_KEYWORDS else name] = value
56
+ return query
57
+
58
+
59
+ def build_url(
60
+ path: Optional[str],
61
+ version: Optional[str] = None,
62
+ query: Optional[Mapping[str, Any]] = None,
63
+ ) -> str:
64
+ """Build the request URL.
65
+
66
+ An absolute ``path`` is used verbatim and ``version`` and ``query`` are ignored, because the
67
+ URL already carries them. That is what makes ``@odata.nextLink`` echo-back and pre-authenticated
68
+ download URLs work with no special case.
69
+ """
70
+ if not path or not path.strip():
71
+ raise GraphError(0, "invalidRequest", "'path' is required")
72
+
73
+ if _ABSOLUTE.match(path):
74
+ if not path.lower().startswith("https://"):
75
+ raise GraphError(0, "invalidRequest", "an absolute 'path' must use https")
76
+ return path
77
+
78
+ resolved = _resolve_version(version)
79
+ suffix = path if path.startswith("/") else "/" + path
80
+ url = f"{BASE_URL}/{resolved}{suffix}"
81
+
82
+ if query:
83
+ # safe="$" keeps $select readable on the wire; Graph accepts either form.
84
+ encoded = urlencode({k: _literal(v) for k, v in query.items()}, safe="$")
85
+ url = f"{url}{'&' if '?' in url else '?'}{encoded}"
86
+ return url
87
+
88
+
89
+ def _literal(value: Any) -> str:
90
+ """OData is case-sensitive about booleans: ``$count=true``, never ``True``."""
91
+ return str(value).lower() if isinstance(value, bool) else str(value)
92
+
93
+
94
+ def segment(value: str) -> str:
95
+ """Percent-encode an id or drive path for use inside a URL path.
96
+
97
+ A guest's user principal name carries ``#EXT#`` and a file may be called ``Q3 #1.xlsx``.
98
+ Unencoded, the ``#`` starts a fragment and the request silently goes to a different path.
99
+ """
100
+ return quote(value, safe=_PATH_SAFE)
101
+
102
+
103
+ def _resolve_version(version: Optional[str]) -> str:
104
+ if not version:
105
+ return DEFAULT_VERSION
106
+ if version not in SUPPORTED_VERSIONS:
107
+ raise GraphError(
108
+ 0, "invalidRequest", f"'version' must be one of {', '.join(SUPPORTED_VERSIONS)}"
109
+ )
110
+ return version
111
+
112
+
113
+ def allowlisted(headers: Mapping[str, str]) -> Dict[str, str]:
114
+ """Only the named headers reach the caller."""
115
+ lowered = {name.lower(): value for name, value in headers.items()}
116
+ return {name: lowered[name.lower()] for name in _HEADER_ALLOWLIST if name.lower() in lowered}
117
+
118
+
119
+ def reject_authorization(headers: Optional[Mapping[str, str]]) -> Dict[str, str]:
120
+ """Forward caller headers as supplied, except Authorization.
121
+
122
+ This package owns authentication; a caller-supplied bearer token would silently bypass the
123
+ credential the client was built with.
124
+ """
125
+ if not headers:
126
+ return {}
127
+
128
+ for name in headers:
129
+ if name.lower() == "authorization":
130
+ raise GraphError(
131
+ 0,
132
+ "invalidRequest",
133
+ "'Authorization' may not be supplied; the client owns authentication",
134
+ )
135
+ return dict(headers)
@@ -0,0 +1,10 @@
1
+ """Resource layer: the plug-and-play surface over the generic transport."""
2
+
3
+ from .base import GraphResource
4
+ from .calendar import Calendar
5
+ from .files import Files
6
+ from .mail import Mail
7
+ from .teams import Teams
8
+ from .users import Users
9
+
10
+ __all__ = ["GraphResource", "Calendar", "Files", "Mail", "Teams", "Users"]
@@ -0,0 +1,70 @@
1
+ """Shared behaviour for any Graph collection.
2
+
3
+ Every Graph collection supports the same operations over a different path, which is a real
4
+ variation point rather than a speculative one: a base class plus a thin subclass per resource
5
+ means adding a resource later is one class and nothing else moves.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, AsyncIterator, Dict, List, Optional, Sequence, Tuple
11
+
12
+ from .._request import segment
13
+
14
+ __all__ = ["GraphResource"]
15
+
16
+
17
+ class GraphResource:
18
+ """Base for a Graph collection. Subclasses set ``path`` and ``scopes``."""
19
+
20
+ #: The collection's path, e.g. ``/me/messages``.
21
+ path: str = ""
22
+
23
+ #: The delegated permissions this resource needs, for error messages and scope assembly.
24
+ scopes: Tuple[str, ...] = ()
25
+
26
+ def __init__(self, client: Any) -> None:
27
+ self._client = client
28
+
29
+ # ── the shared five ──────────────────────────────────────────────────────
30
+
31
+ def list(self, **options: Any) -> AsyncIterator[Dict[str, Any]]:
32
+ """Every item, one page at a time. Returns an async generator."""
33
+ return self._client.paged(self.path, **options)
34
+
35
+ async def get(self, item_id: str, **options: Any) -> Dict[str, Any]:
36
+ return await self._client.get(f"{self.path}/{segment(item_id)}", **options)
37
+
38
+ async def create(self, body: Dict[str, Any], **options: Any) -> Dict[str, Any]:
39
+ return await self._client.post(self.path, body=body, **options)
40
+
41
+ async def update(self, item_id: str, body: Dict[str, Any], **options: Any) -> Dict[str, Any]:
42
+ return await self._client.patch(f"{self.path}/{segment(item_id)}", body=body, **options)
43
+
44
+ async def delete(self, item_id: str, **options: Any) -> None:
45
+ await self._client.delete(f"{self.path}/{segment(item_id)}", **options)
46
+
47
+ # ── the fast path ────────────────────────────────────────────────────────
48
+
49
+ async def get_many(
50
+ self, item_ids: Sequence[str], select: Optional[str] = None
51
+ ) -> List[Dict[str, Any]]:
52
+ """Fetch many items in as few round-trips as Graph allows.
53
+
54
+ Chunked into batches of twenty and dispatched concurrently, so two hundred lookups cost ten
55
+ round-trips rather than two hundred. Results come back in the order the ids were given.
56
+ """
57
+ query = f"?$select={select}" if select else ""
58
+ requests = [{"method": "GET", "url": f"{self.path}/{segment(i)}{query}"} for i in item_ids]
59
+ return await self._client.batch(requests)
60
+
61
+ # ── helpers for subclasses ───────────────────────────────────────────────
62
+
63
+ async def _action(self, item_id: str, action: str, body: Any = None) -> Any:
64
+ """POST to an action on one item, e.g. ``/me/messages/{id}/reply``."""
65
+ return await self._client.post(f"{self.path}/{segment(item_id)}/{action}", body=body)
66
+
67
+ async def _collection_action(self, action: str, body: Any = None) -> Any:
68
+ """POST to an action on the collection's owner, e.g. ``/me/sendMail``."""
69
+ owner = self.path.rsplit("/", 1)[0] or "/me"
70
+ return await self._client.post(f"{owner}/{action}", body=body)
@@ -0,0 +1,194 @@
1
+ """Calendar and meetings.
2
+
3
+ As with mail, the value here is the payload. A Teams meeting needs ``isOnlineMeeting`` paired with
4
+ ``onlineMeetingProvider``; attendees are objects carrying a ``type``; and every time is a
5
+ ``dateTime``/``timeZone`` pair rather than an ISO string. None of that is guessable from the call
6
+ site, so it lives here once.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from datetime import datetime, timedelta, timezone
12
+ from typing import Any, AsyncIterator, Dict, Iterable, List, Optional, Sequence, Union
13
+
14
+ from .._errors import GraphError
15
+ from .._request import build_url, odata
16
+ from .._scopes import Scopes
17
+ from .base import GraphResource
18
+
19
+ __all__ = ["Calendar"]
20
+
21
+ Attendees = Union[str, Sequence[str], None]
22
+
23
+ _RESPONSES = {"accept", "decline", "tentativelyAccept"}
24
+
25
+
26
+ def _graph_time(moment: datetime, tz: str) -> Dict[str, str]:
27
+ """Graph wants a naive local time plus a named zone, not an offset."""
28
+ if moment.tzinfo is not None:
29
+ moment = moment.astimezone(timezone.utc).replace(tzinfo=None)
30
+ tz = "UTC"
31
+ return {"dateTime": moment.isoformat(timespec="seconds"), "timeZone": tz}
32
+
33
+
34
+ def _attendees(value: Attendees, kind: str = "required") -> List[Dict[str, Any]]:
35
+ if not value:
36
+ return []
37
+ addresses = [value] if isinstance(value, str) else list(value)
38
+ return [
39
+ {"emailAddress": {"address": address}, "type": kind}
40
+ for address in addresses
41
+ ]
42
+
43
+
44
+ class Calendar(GraphResource):
45
+ """Events on the signed-in user's calendar."""
46
+
47
+ path = "/me/events"
48
+ scopes = Scopes.CALENDARS_READ_WRITE
49
+
50
+ # ── scheduling ───────────────────────────────────────────────────────────
51
+
52
+ def compose(
53
+ self,
54
+ subject: str,
55
+ start: datetime,
56
+ end: datetime,
57
+ attendees: Attendees = None,
58
+ optional_attendees: Attendees = None,
59
+ online: bool = False,
60
+ location: Optional[str] = None,
61
+ body: str = "",
62
+ html: bool = False,
63
+ timezone_name: str = "UTC",
64
+ reminder_minutes: Optional[int] = None,
65
+ all_day: bool = False,
66
+ ) -> Dict[str, Any]:
67
+ """Build the event payload without creating it."""
68
+ if end <= start:
69
+ raise GraphError(0, "invalidRequest", "'end' must be after 'start'")
70
+
71
+ event: Dict[str, Any] = {
72
+ "subject": subject,
73
+ "start": _graph_time(start, timezone_name),
74
+ "end": _graph_time(end, timezone_name),
75
+ }
76
+
77
+ people = _attendees(attendees) + _attendees(optional_attendees, "optional")
78
+ if people:
79
+ event["attendees"] = people
80
+ if body:
81
+ event["body"] = {"contentType": "HTML" if html else "Text", "content": body}
82
+ if location:
83
+ event["location"] = {"displayName": location}
84
+ if reminder_minutes is not None:
85
+ event["reminderMinutesBeforeStart"] = reminder_minutes
86
+ event["isReminderOn"] = True
87
+ if all_day:
88
+ event["isAllDay"] = True
89
+
90
+ if online:
91
+ # Both fields are needed; isOnlineMeeting alone produces an event with no join link.
92
+ event["isOnlineMeeting"] = True
93
+ event["onlineMeetingProvider"] = "teamsForBusiness"
94
+
95
+ return event
96
+
97
+ async def schedule(self, **fields: Any) -> Dict[str, Any]:
98
+ """Create an event. Takes everything ``compose`` takes.
99
+
100
+ With ``online=True`` the response carries ``onlineMeeting.joinUrl``.
101
+ """
102
+ return await self.create(self.compose(**fields))
103
+
104
+ async def schedule_many(self, events: Sequence[Dict[str, Any]]) -> List[Dict[str, Any]]:
105
+ """Create many events in as few round-trips as Graph allows."""
106
+ requests = [{
107
+ "method": "POST",
108
+ "url": self.path,
109
+ "headers": {"Content-Type": "application/json"},
110
+ "body": self.compose(**fields),
111
+ } for fields in events]
112
+ return await self._client.batch(requests)
113
+
114
+ # ── reading ──────────────────────────────────────────────────────────────
115
+
116
+ def upcoming(
117
+ self,
118
+ days: int = 7,
119
+ select: str = "id,subject,start,end,location,onlineMeeting,organizer,attendees",
120
+ top: int = 50,
121
+ ) -> AsyncIterator[Dict[str, Any]]:
122
+ """Events in the next ``days``, soonest first.
123
+
124
+ Uses calendarView, which is the endpoint that expands recurring series -- listing /events
125
+ returns the series master instead of its occurrences.
126
+ """
127
+ now = datetime.now(timezone.utc)
128
+ query = odata({
129
+ "select": select,
130
+ "top": top,
131
+ "orderby": "start/dateTime",
132
+ })
133
+ query["startDateTime"] = now.isoformat(timespec="seconds").replace("+00:00", "Z")
134
+ query["endDateTime"] = (now + timedelta(days=days)).isoformat(
135
+ timespec="seconds").replace("+00:00", "Z")
136
+
137
+ return self._client.paged(build_url("/me/calendarView", None, query))
138
+
139
+ # ── responding ───────────────────────────────────────────────────────────
140
+
141
+ async def respond(
142
+ self, event_id: str, response: str, comment: str = "", send_response: bool = True
143
+ ) -> None:
144
+ """Accept, decline or tentatively accept an invitation."""
145
+ if response not in _RESPONSES:
146
+ raise GraphError(
147
+ 0, "invalidRequest", f"'response' must be one of {', '.join(sorted(_RESPONSES))}"
148
+ )
149
+ await self._action(event_id, response, {
150
+ "comment": comment,
151
+ "sendResponse": send_response,
152
+ })
153
+
154
+ async def cancel(self, event_id: str, comment: str = "") -> None:
155
+ """Cancel a meeting you organise, notifying the attendees."""
156
+ await self._action(event_id, "cancel", {"comment": comment})
157
+
158
+ # ── finding a slot ───────────────────────────────────────────────────────
159
+
160
+ async def find_times(
161
+ self,
162
+ attendees: Attendees,
163
+ duration_minutes: int = 30,
164
+ within_days: int = 5,
165
+ minimum_attendance_percent: int = 100,
166
+ ) -> List[Dict[str, Any]]:
167
+ """Ask Graph for slots that suit everyone. Returns the suggestions, best first."""
168
+ now = datetime.now(timezone.utc)
169
+ suggestions = await self._collection_action("findMeetingTimes", {
170
+ "attendees": _attendees(attendees),
171
+ "timeConstraint": {
172
+ "activityDomain": "work",
173
+ "timeSlots": [{
174
+ "start": _graph_time(now, "UTC"),
175
+ "end": _graph_time(now + timedelta(days=within_days), "UTC"),
176
+ }],
177
+ },
178
+ "meetingDuration": f"PT{duration_minutes}M",
179
+ "minimumAttendeePercentage": minimum_attendance_percent,
180
+ "returnSuggestionReasons": True,
181
+ })
182
+ return (suggestions or {}).get("meetingTimeSuggestions", [])
183
+
184
+ async def free_busy(
185
+ self, people: Iterable[str], start: datetime, end: datetime, interval_minutes: int = 30
186
+ ) -> List[Dict[str, Any]]:
187
+ """Each person's availability over a window."""
188
+ schedules = await self._collection_action("calendar/getSchedule", {
189
+ "schedules": list(people),
190
+ "startTime": _graph_time(start, "UTC"),
191
+ "endTime": _graph_time(end, "UTC"),
192
+ "availabilityViewInterval": interval_minutes,
193
+ })
194
+ return (schedules or {}).get("value", [])
@@ -0,0 +1,149 @@
1
+ """OneDrive and SharePoint files.
2
+
3
+ The thing that makes Graph's file API hard to write against is addressing. The same item is
4
+ ``/me/drive/items/01ABC...`` by id and ``/me/drive/root:/reports/q3.xlsx:`` by path -- with a colon
5
+ opening the path segment and *another* colon closing it before whatever comes next. Forgetting the
6
+ trailing colon gives a 400 that says nothing about colons.
7
+
8
+ ``_address`` decides between the two on one rule: a leading slash means a path, anything else is an
9
+ id. Every method here takes either.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+ from typing import Any, AsyncIterator, Dict, Optional, Union
16
+
17
+ from .._errors import GraphError
18
+ from .._request import segment
19
+ from .._scopes import Scopes
20
+ from .base import GraphResource
21
+
22
+ __all__ = ["Files"]
23
+
24
+ DRIVE = "/me/drive"
25
+
26
+ DEFAULT_FIELDS = "id,name,size,webUrl,lastModifiedDateTime,file,folder"
27
+
28
+ #: Graph's own names for what happens when a new folder's name is taken.
29
+ _CONFLICT = ("rename", "replace", "fail")
30
+
31
+ #: Who a sharing link works for. "anonymous" is frequently disabled by tenant policy.
32
+ _SCOPES = ("anonymous", "organization", "users")
33
+
34
+ _LINK_KINDS = ("view", "edit", "embed")
35
+
36
+
37
+ class Files(GraphResource):
38
+ """Items in the signed-in user's drive.
39
+
40
+ Every method takes either a drive path (``"/reports/q3.xlsx"``, leading slash) or an item id.
41
+ """
42
+
43
+ path = f"{DRIVE}/items"
44
+ scopes = Scopes.FILES_READ_WRITE
45
+
46
+ @staticmethod
47
+ def _address(item: str) -> str:
48
+ """The endpoint for one item, by path or by id."""
49
+ if not item or not item.strip():
50
+ raise GraphError(0, "invalidRequest", "'item' is required")
51
+ if not item.startswith("/"):
52
+ return f"{DRIVE}/items/{segment(item)}"
53
+ if item == "/":
54
+ return f"{DRIVE}/root"
55
+ # The closing colon is what separates the path from whatever follows it.
56
+ return f"{DRIVE}/root:{segment(item.rstrip('/'))}:"
57
+
58
+ # ── moving bytes ─────────────────────────────────────────────────────────
59
+
60
+ async def upload(self, source: Union[str, Path], to: Optional[str] = None) -> Dict[str, Any]:
61
+ """Send a local file to the drive, replacing whatever is at the destination.
62
+
63
+ ``to`` is the destination drive path; omit it and the file keeps its own name at the root.
64
+ Files above 4 MiB switch to a resumable upload session on their own -- the caller never
65
+ picks a strategy, and the path is the same either way.
66
+ """
67
+ local = Path(source)
68
+ if not local.is_file():
69
+ raise GraphError(0, "invalidRequest", f"'{source}' does not exist")
70
+
71
+ destination = to or f"/{local.name}"
72
+ if not destination.startswith("/"):
73
+ destination = "/" + destination
74
+
75
+ return await self._client.upload(f"{self._address(destination)}/content", str(local))
76
+
77
+ async def download(self, item: str, dest_path: Union[str, Path]) -> Dict[str, Any]:
78
+ """Stream a file to disk. The destination directory must already exist.
79
+
80
+ Graph answers the content endpoint with a redirect to a short-lived, pre-authenticated URL
81
+ on another host. The transport follows it and deliberately does not attach the bearer token
82
+ to that second hop.
83
+ """
84
+ return await self._client.download(f"{self._address(item)}/content", str(dest_path))
85
+
86
+ # ── looking around ───────────────────────────────────────────────────────
87
+
88
+ def folder(
89
+ self, path: str = "/", select: str = DEFAULT_FIELDS, top: int = 200
90
+ ) -> AsyncIterator[Dict[str, Any]]:
91
+ """Everything directly inside a folder. Defaults to the drive root."""
92
+ return self._client.paged(f"{self._address(path)}/children", select=select, top=top)
93
+
94
+ def search(self, query: str, select: str = DEFAULT_FIELDS) -> AsyncIterator[Dict[str, Any]]:
95
+ """Search the whole drive by name and content."""
96
+ if not query:
97
+ raise GraphError(0, "invalidRequest", "'query' is required")
98
+ # Graph spells this one as a function on the path, not as $search.
99
+ escaped = segment(query.replace("'", "''")).replace("/", "%2F")
100
+ return self._client.paged(f"{DRIVE}/root/search(q='{escaped}')", select=select)
101
+
102
+ async def metadata(self, item: str, select: str = DEFAULT_FIELDS) -> Dict[str, Any]:
103
+ """One item's properties, without its bytes."""
104
+ return await self._client.get(self._address(item), select=select)
105
+
106
+ # ── changing things ──────────────────────────────────────────────────────
107
+
108
+ async def make_folder(self, path: str, conflict: str = "fail") -> Dict[str, Any]:
109
+ """Create a folder. ``path`` is the full drive path of the folder to create."""
110
+ if not path.startswith("/"):
111
+ path = "/" + path
112
+ parent, _, name = path.rstrip("/").rpartition("/")
113
+ if not name:
114
+ raise GraphError(0, "invalidRequest", "'path' must name the folder to create")
115
+
116
+ if conflict not in _CONFLICT:
117
+ raise GraphError(
118
+ 0, "invalidRequest", f"'conflict' must be one of {', '.join(_CONFLICT)}"
119
+ )
120
+
121
+ return await self._client.post(f"{self._address(parent or '/')}/children", body={
122
+ "name": name,
123
+ "folder": {},
124
+ "@microsoft.graph.conflictBehavior": conflict,
125
+ })
126
+
127
+ async def remove(self, item: str) -> None:
128
+ """Move an item to the recycle bin."""
129
+ await self._client.delete(self._address(item))
130
+
131
+ async def share_link(
132
+ self, item: str, kind: str = "view", scope: str = "organization"
133
+ ) -> str:
134
+ """Create a sharing link and return just the URL.
135
+
136
+ ``scope="anonymous"`` produces a link anyone can open, and is frequently disabled by tenant
137
+ policy -- that arrives as an ``accessDenied`` error rather than a working link.
138
+ """
139
+ if kind not in _LINK_KINDS:
140
+ raise GraphError(
141
+ 0, "invalidRequest", f"'kind' must be one of {', '.join(_LINK_KINDS)}"
142
+ )
143
+ if scope not in _SCOPES:
144
+ raise GraphError(0, "invalidRequest", f"'scope' must be one of {', '.join(_SCOPES)}")
145
+
146
+ created = await self._client.post(
147
+ f"{self._address(item)}/createLink", body={"type": kind, "scope": scope}
148
+ )
149
+ return ((created or {}).get("link") or {}).get("webUrl", "")