canvas 0.24.0__py3-none-any.whl → 0.26.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.

Potentially problematic release.


This version of canvas might be problematic. Click here for more details.

Files changed (29) hide show
  1. {canvas-0.24.0.dist-info → canvas-0.26.0.dist-info}/METADATA +1 -1
  2. {canvas-0.24.0.dist-info → canvas-0.26.0.dist-info}/RECORD +29 -18
  3. canvas_generated/messages/effects_pb2.py +2 -2
  4. canvas_generated/messages/effects_pb2.pyi +2 -0
  5. canvas_generated/messages/events_pb2.py +2 -2
  6. canvas_generated/messages/events_pb2.pyi +4 -0
  7. canvas_sdk/effects/simple_api.py +83 -0
  8. canvas_sdk/handlers/base.py +4 -0
  9. canvas_sdk/handlers/simple_api/__init__.py +22 -0
  10. canvas_sdk/handlers/simple_api/api.py +328 -0
  11. canvas_sdk/handlers/simple_api/exceptions.py +39 -0
  12. canvas_sdk/handlers/simple_api/security.py +184 -0
  13. canvas_sdk/tests/handlers/test_simple_api.py +828 -0
  14. canvas_sdk/v1/data/__init__.py +4 -2
  15. canvas_sdk/v1/data/appointment.py +22 -0
  16. canvas_sdk/v1/data/staff.py +25 -1
  17. plugin_runner/plugin_runner.py +27 -0
  18. plugin_runner/sandbox.py +1 -0
  19. plugin_runner/tests/fixtures/plugins/test_simple_api/CANVAS_MANIFEST.json +47 -0
  20. plugin_runner/tests/fixtures/plugins/test_simple_api/README.md +11 -0
  21. plugin_runner/tests/fixtures/plugins/test_simple_api/__init__.py +0 -0
  22. plugin_runner/tests/fixtures/plugins/test_simple_api/protocols/__init__.py +0 -0
  23. plugin_runner/tests/fixtures/plugins/test_simple_api/protocols/my_protocol.py +43 -0
  24. plugin_runner/tests/test_plugin_runner.py +71 -1
  25. protobufs/canvas_generated/messages/effects.proto +2 -0
  26. protobufs/canvas_generated/messages/events.proto +4 -0
  27. {canvas-0.24.0.dist-info → canvas-0.26.0.dist-info}/WHEEL +0 -0
  28. {canvas-0.24.0.dist-info → canvas-0.26.0.dist-info}/entry_points.txt +0 -0
  29. /plugin_runner/tests/data/plugins/.gitkeep → /canvas_sdk/tests/handlers/__init__.py +0 -0
@@ -0,0 +1,328 @@
1
+ import json
2
+ import traceback
3
+ from abc import ABC
4
+ from base64 import b64decode
5
+ from collections.abc import Callable
6
+ from functools import cached_property
7
+ from http import HTTPStatus
8
+ from typing import Any, TypeVar
9
+ from urllib.parse import parse_qs
10
+
11
+ from requests.structures import CaseInsensitiveDict
12
+
13
+ from canvas_sdk.effects import Effect, EffectType
14
+ from canvas_sdk.effects.simple_api import JSONResponse, Response
15
+ from canvas_sdk.events import Event, EventType
16
+ from canvas_sdk.handlers.base import BaseHandler
17
+ from logger import log
18
+ from plugin_runner.exceptions import PluginError
19
+
20
+ from .exceptions import AuthenticationError, InvalidCredentialsError
21
+ from .security import Credentials
22
+
23
+ # TODO: Routing by path regex?
24
+ # TODO: Support multipart/form-data by adding helpers to the request class
25
+ # TODO: Log requests in a format similar to other API frameworks (probably need effect metadata)
26
+ # TODO: Support Effect metadata that is separate from payload
27
+ # TODO: Encode event payloads with MessagePack instead of JSON
28
+
29
+
30
+ JSON = dict[str, "JSON"] | list["JSON"] | int | float | str | bool | None
31
+
32
+
33
+ class Request:
34
+ """Request class for incoming requests to the API."""
35
+
36
+ def __init__(self, event: Event) -> None:
37
+ self.method = event.context["method"]
38
+ self.path = event.context["path"]
39
+ self.query_string = event.context["query_string"]
40
+ self._body = event.context["body"]
41
+ self.headers: CaseInsensitiveDict = CaseInsensitiveDict(event.context["headers"])
42
+
43
+ self.query_params = parse_qs(self.query_string)
44
+ self.content_type = self.headers.get("Content-Type")
45
+
46
+ @cached_property
47
+ def body(self) -> bytes:
48
+ """Decode and return the response body."""
49
+ return b64decode(self._body)
50
+
51
+ def json(self) -> JSON:
52
+ """Return the response JSON."""
53
+ return json.loads(self.body)
54
+
55
+ def text(self) -> str:
56
+ """Return the response body as plain text."""
57
+ return self.body.decode()
58
+
59
+
60
+ SimpleAPIType = TypeVar("SimpleAPIType", bound="SimpleAPIBase")
61
+
62
+ RouteHandler = Callable[[SimpleAPIType], list[Response | Effect]]
63
+
64
+
65
+ def get(path: str) -> Callable[[RouteHandler], RouteHandler]:
66
+ """Decorator for adding API GET routes."""
67
+ return _handler_decorator("GET", path)
68
+
69
+
70
+ def post(path: str) -> Callable[[RouteHandler], RouteHandler]:
71
+ """Decorator for adding API POST routes."""
72
+ return _handler_decorator("POST", path)
73
+
74
+
75
+ def put(path: str) -> Callable[[RouteHandler], RouteHandler]:
76
+ """Decorator for adding API PUT routes."""
77
+ return _handler_decorator("PUT", path)
78
+
79
+
80
+ def delete(path: str) -> Callable[[RouteHandler], RouteHandler]:
81
+ """Decorator for adding API DELETE routes."""
82
+ return _handler_decorator("DELETE", path)
83
+
84
+
85
+ def patch(path: str) -> Callable[[RouteHandler], RouteHandler]:
86
+ """Decorator for adding API PATCH routes."""
87
+ return _handler_decorator("PATCH", path)
88
+
89
+
90
+ def _handler_decorator(method: str, path: str) -> Callable[[RouteHandler], RouteHandler]:
91
+ if not path.startswith("/"):
92
+ raise PluginError(f"Route path '{path}' must start with a forward slash")
93
+
94
+ def decorator(handler: RouteHandler) -> RouteHandler:
95
+ """Mark the handler with the HTTP method and path."""
96
+ handler.route = (method, path) # type: ignore[attr-defined]
97
+
98
+ return handler
99
+
100
+ return decorator
101
+
102
+
103
+ class SimpleAPIBase(BaseHandler, ABC):
104
+ """Abstract base class for HTTP APIs."""
105
+
106
+ RESPONDS_TO = [
107
+ EventType.Name(EventType.SIMPLE_API_AUTHENTICATE),
108
+ EventType.Name(EventType.SIMPLE_API_REQUEST),
109
+ ]
110
+
111
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
112
+ super().__init__(*args, **kwargs)
113
+
114
+ # Build the registry of routes so that requests can be routed to the correct handler. This
115
+ # is done by iterating over the methods on the class instance and looking for methods that
116
+ # have been marked by the handler decorators (get, post, etc.).
117
+ self._routes: dict[tuple[str, str], Callable] = {}
118
+ for attr in self.__class__.__dict__.values():
119
+ if callable(attr) and (route := getattr(attr, "route", None)):
120
+ method, relative_path = route
121
+ path = f"{self._path_prefix()}{relative_path}"
122
+ self._routes[(method, path)] = attr
123
+
124
+ def _path_prefix(self) -> str:
125
+ return ""
126
+
127
+ @cached_property
128
+ def request(self) -> Request:
129
+ """Return the request object from the event."""
130
+ return Request(self.event)
131
+
132
+ def compute(self) -> list[Effect]:
133
+ """Handle the authenticate or request event."""
134
+ try:
135
+ if self.event.type == EventType.SIMPLE_API_AUTHENTICATE:
136
+ return self._authenticate()
137
+ elif self.event.type == EventType.SIMPLE_API_REQUEST:
138
+ return self._handle_request()
139
+ else:
140
+ raise AssertionError(f"Cannot handle event type {EventType.Name(self.event.type)}")
141
+ except Exception as exception:
142
+ for error_line_with_newlines in traceback.format_exception(exception):
143
+ for error_line in error_line_with_newlines.split("\n"):
144
+ log.error(error_line)
145
+
146
+ return [Response(status_code=HTTPStatus.INTERNAL_SERVER_ERROR).apply()]
147
+
148
+ def _authenticate(self) -> list[Effect]:
149
+ """Authenticate the request."""
150
+ try:
151
+ # Create the credentials object
152
+ credentials_cls = self.authenticate.__annotations__.get("credentials")
153
+ if not credentials_cls or not issubclass(credentials_cls, Credentials):
154
+ raise PluginError(
155
+ "Cannot determine authentication scheme; please specify the type of "
156
+ "credentials your endpoint requires"
157
+ )
158
+ credentials = credentials_cls(self.request)
159
+
160
+ # Pass the credentials object into the developer-defined authenticate method. If
161
+ # authentication succeeds, return a 200 back to home-app, otherwise return a response
162
+ # with the error
163
+ if self.authenticate(credentials):
164
+ return [Response(status_code=HTTPStatus.OK).apply()]
165
+ else:
166
+ raise InvalidCredentialsError
167
+ except AuthenticationError as error:
168
+ return [
169
+ JSONResponse(
170
+ content={"error": str(error)}, status_code=HTTPStatus.UNAUTHORIZED
171
+ ).apply()
172
+ ]
173
+
174
+ def _handle_request(self) -> list[Effect]:
175
+ """Route the incoming request to the handler method based on the HTTP method and path."""
176
+ # Get the handler method
177
+ handler = self._routes[(self.request.method, self.request.path)]
178
+
179
+ # Handle the request
180
+ effects = handler(self)
181
+
182
+ # Iterate over the returned effects and:
183
+ # 1. Change any response objects to response effects
184
+ # 2. Detect if the handler returned multiple responses, and if it did, log an error and
185
+ # return only a response effect with a 500 Internal Server Error.
186
+ # 3. Detect if the handler returned an error response object, and if it did, return only
187
+ # the response effect.
188
+ response_found = False
189
+ for index, effect in enumerate(effects):
190
+ # Change the response object to a response effect
191
+ if isinstance(effect, Response):
192
+ effects[index] = effect.apply()
193
+
194
+ if effects[index].type == EffectType.SIMPLE_API_RESPONSE:
195
+ # If a response has already been found, return an error response immediately
196
+ if response_found:
197
+ log.error(f"Multiple responses provided by {SimpleAPI.__name__} handler")
198
+ return [Response(status_code=HTTPStatus.INTERNAL_SERVER_ERROR).apply()]
199
+ else:
200
+ response_found = True
201
+
202
+ # Get the status code of the response. If the initial response was an object and
203
+ # not an effect, get it from the response object to avoid having to deserialize
204
+ # the payload.
205
+ if isinstance(effect, Response):
206
+ status_code = effect.status_code
207
+ else:
208
+ status_code = json.loads(effect.payload)["status_code"]
209
+
210
+ # If the handler returned an error response, return only that response effect
211
+ # and omit any other included effects
212
+ if 400 <= status_code <= 599:
213
+ return [effects[index]]
214
+
215
+ return effects
216
+
217
+ def accept_event(self) -> bool:
218
+ """Ignore the event if the handler does not implement the route."""
219
+ return (self.request.method, self.request.path) in self._routes
220
+
221
+ def authenticate(self, credentials: Credentials) -> bool:
222
+ """Method the user should override to authenticate requests."""
223
+ return False
224
+
225
+
226
+ class SimpleAPI(SimpleAPIBase, ABC):
227
+ """Base class for HTTP APIs."""
228
+
229
+ def __init_subclass__(cls, **kwargs: Any) -> None:
230
+ """
231
+ Detect errors the user makes when defining their handler.
232
+
233
+ Prevent developers from defining multiple handlers for the same route, and from defining
234
+ methods that clash with base class methods.
235
+ """
236
+ if (prefix := getattr(cls, "PREFIX", None)) and not prefix.startswith("/"):
237
+ raise PluginError(f"Route prefix '{prefix}' must start with a forward slash")
238
+
239
+ super().__init_subclass__(**kwargs)
240
+
241
+ routes = set()
242
+ route_handler_method_names = set()
243
+ for name, value in cls.__dict__.items():
244
+ if callable(value) and hasattr(value, "route"):
245
+ if value.route in routes:
246
+ method, path = value.route
247
+ raise PluginError(
248
+ f"The route {method} {path} must only be handled by one route handler"
249
+ )
250
+ routes.add(value.route)
251
+ route_handler_method_names.add(name)
252
+
253
+ for superclass in cls.__mro__[1:]:
254
+ if names := route_handler_method_names.intersection(superclass.__dict__):
255
+ raise PluginError(
256
+ f"{SimpleAPI.__name__} subclass route handler methods are overriding base "
257
+ f"class attributes: {', '.join(f'{cls.__name__}.{name}' for name in names)}"
258
+ )
259
+
260
+ def _path_prefix(self) -> str:
261
+ return getattr(self, "PREFIX", None) or ""
262
+
263
+
264
+ class SimpleAPIRoute(SimpleAPIBase, ABC):
265
+ """Base class for HTTP API routes."""
266
+
267
+ def __init_subclass__(cls, **kwargs: Any) -> None:
268
+ """Automatically mark the get, post, put, delete, and patch methods as handler methods."""
269
+ if hasattr(cls, "PREFIX"):
270
+ raise PluginError(
271
+ f"Setting a PREFIX value on a {SimpleAPIRoute.__name__} is not allowed"
272
+ )
273
+
274
+ super().__init_subclass__(**kwargs)
275
+
276
+ for attr_name, attr_value in cls.__dict__.items():
277
+ decorator: Callable | None
278
+ match attr_name:
279
+ case "get":
280
+ decorator = get
281
+ case "post":
282
+ decorator = post
283
+ case "put":
284
+ decorator = put
285
+ case "delete":
286
+ decorator = delete
287
+ case "patch":
288
+ decorator = patch
289
+ case _:
290
+ decorator = None
291
+
292
+ if not callable(attr_value):
293
+ continue
294
+
295
+ if hasattr(attr_value, "route"):
296
+ raise PluginError(
297
+ f"Using the api decorator on subclasses of {SimpleAPIRoute.__name__} is not "
298
+ "allowed"
299
+ )
300
+
301
+ if decorator is None:
302
+ continue
303
+
304
+ path = getattr(cls, "PATH", None)
305
+ if not path:
306
+ raise PluginError(f"PATH must be specified on a {SimpleAPIRoute.__name__}")
307
+
308
+ decorator(path)(attr_value)
309
+
310
+ def get(self) -> list[Response | Effect]:
311
+ """Stub method for GET handler."""
312
+ return []
313
+
314
+ def post(self) -> list[Response | Effect]:
315
+ """Stub method for POST handler."""
316
+ return []
317
+
318
+ def put(self) -> list[Response | Effect]:
319
+ """Stub method for PUT handler."""
320
+ return []
321
+
322
+ def delete(self) -> list[Response | Effect]:
323
+ """Stub method for DELETE handler."""
324
+ return []
325
+
326
+ def patch(self) -> list[Response | Effect]:
327
+ """Stub method for PATCH handler."""
328
+ return []
@@ -0,0 +1,39 @@
1
+ class SimpleAPIException(Exception):
2
+ """Base class for all SimpleAPI exceptions."""
3
+
4
+ def __init__(self, message: str) -> None:
5
+ super().__init__(message)
6
+
7
+
8
+ class AuthenticationError(SimpleAPIException):
9
+ """Base class for all authentication exceptions."""
10
+
11
+ pass
12
+
13
+
14
+ class NoAuthorizationHeaderError(AuthenticationError):
15
+ """Exception class for requests without an Authorization header."""
16
+
17
+ def __init__(self) -> None:
18
+ super().__init__("Request has no Authorization header")
19
+
20
+
21
+ class AuthenticationSchemeError(AuthenticationError):
22
+ """Exception class for requests that do not have a valid authentication scheme."""
23
+
24
+ def __init__(self) -> None:
25
+ super().__init__("Authorization header has no recognized authentication scheme")
26
+
27
+
28
+ class InvalidCredentialsFormatError(AuthenticationError):
29
+ """Exception class for requests that have incorrectly-formatted credentials."""
30
+
31
+ def __init__(self) -> None:
32
+ super().__init__("Provided credentials are incorrectly formatted")
33
+
34
+
35
+ class InvalidCredentialsError(AuthenticationError):
36
+ """Exception class for requests that have invalid credentials."""
37
+
38
+ def __init__(self) -> None:
39
+ super().__init__("Provided credentials are invalid")
@@ -0,0 +1,184 @@
1
+ from base64 import b64decode
2
+ from secrets import compare_digest
3
+ from typing import TYPE_CHECKING, Any, Protocol
4
+
5
+ from logger import log
6
+
7
+ from .exceptions import (
8
+ AuthenticationSchemeError,
9
+ InvalidCredentialsError,
10
+ InvalidCredentialsFormatError,
11
+ NoAuthorizationHeaderError,
12
+ )
13
+
14
+ if TYPE_CHECKING:
15
+ from .api import Request
16
+
17
+
18
+ class Credentials:
19
+ """
20
+ Credentials base class.
21
+
22
+ Performs no parsing of the request Authorization header. This class can be used as a base class
23
+ for defining custom credentials classes, or can be specified by a developer as the credentials
24
+ class if they wish to just access the request directly in their authentication method.
25
+ """
26
+
27
+ def __init__(self, request: "Request") -> None:
28
+ pass
29
+
30
+
31
+ class BasicCredentials(Credentials):
32
+ """
33
+ Basic authentication credentials class.
34
+
35
+ Parses and decodes the username and password from the request Authorization header and saves
36
+ them as attributes.
37
+ """
38
+
39
+ def __init__(self, request: "Request") -> None:
40
+ super().__init__(request)
41
+
42
+ authorization = request.headers.get("Authorization")
43
+ if not authorization:
44
+ raise NoAuthorizationHeaderError
45
+
46
+ scheme, delimiter, value = authorization.partition(" ")
47
+ if delimiter != " ":
48
+ raise AuthenticationSchemeError
49
+ if scheme.lower() != "basic":
50
+ raise AuthenticationSchemeError
51
+
52
+ try:
53
+ value = b64decode(value.encode()).decode()
54
+ except Exception as exception:
55
+ raise InvalidCredentialsFormatError from exception
56
+
57
+ username, delimiter, password = value.partition(":")
58
+ if delimiter != ":":
59
+ raise InvalidCredentialsFormatError
60
+ if not username or not password:
61
+ raise InvalidCredentialsFormatError
62
+
63
+ self.username = username
64
+ self.password = password
65
+
66
+
67
+ class BearerCredentials(Credentials):
68
+ """
69
+ Bearer authentication credentials class.
70
+
71
+ Parses the token from the request Authorization header and saves it as an attribute.
72
+ """
73
+
74
+ def __init__(self, request: "Request") -> None:
75
+ super().__init__(request)
76
+
77
+ authorization = request.headers.get("Authorization")
78
+ if not authorization:
79
+ raise NoAuthorizationHeaderError
80
+
81
+ scheme, delimiter, token = authorization.partition(" ")
82
+ if delimiter != " ":
83
+ raise AuthenticationSchemeError
84
+ if scheme.lower() != "bearer":
85
+ raise AuthenticationSchemeError
86
+ if not token:
87
+ raise InvalidCredentialsFormatError
88
+
89
+ self.token = token
90
+
91
+
92
+ class APIKeyCredentials(Credentials):
93
+ """
94
+ API Key credentials class.
95
+
96
+ Obtains the API key from the request Authorization header and saves it as an attribute.
97
+
98
+ The default header name is "Authorization", but can be changed by overriding the HEADER_NAME
99
+ class variable in subclasses.
100
+ """
101
+
102
+ HEADER_NAME = "Authorization"
103
+
104
+ def __init__(self, request: "Request") -> None:
105
+ super().__init__(request)
106
+
107
+ if self.HEADER_NAME not in request.headers:
108
+ raise NoAuthorizationHeaderError
109
+
110
+ key = request.headers.get(self.HEADER_NAME)
111
+ if not key:
112
+ raise InvalidCredentialsFormatError
113
+
114
+ self.key = key
115
+
116
+
117
+ class AuthSchemeMixin(Protocol):
118
+ """Protocol for authentication scheme mixins."""
119
+
120
+ secrets: dict[str, str]
121
+
122
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
123
+ super().__init__(*args, **kwargs)
124
+
125
+ def authenticate(self, credentials: Credentials) -> bool:
126
+ """Authenticate the request."""
127
+ ...
128
+
129
+
130
+ class BasicAuthMixin(AuthSchemeMixin):
131
+ """
132
+ Basic authentication scheme mixin.
133
+
134
+ Provides an implementation of the authenticate method for Basic authentication.
135
+ """
136
+
137
+ USERNAME_SECRET_NAME = "simpleapi-basic-username"
138
+ PASSWORD_SECRET_NAME = "simpleapi-basic-password"
139
+
140
+ def authenticate(self, credentials: BasicCredentials) -> bool: # type: ignore[override]
141
+ """Authenticate the request."""
142
+ try:
143
+ username = self.secrets[self.USERNAME_SECRET_NAME]
144
+ password = self.secrets[self.PASSWORD_SECRET_NAME]
145
+ except KeyError as error:
146
+ log.error(
147
+ "SimpleAPI secrets for Basic authentication are not set; please set values for "
148
+ f"{self.USERNAME_SECRET_NAME} and {self.PASSWORD_SECRET_NAME}"
149
+ )
150
+ raise InvalidCredentialsError from error
151
+
152
+ if not (
153
+ compare_digest(credentials.username.encode(), username.encode())
154
+ and compare_digest(credentials.password.encode(), password.encode())
155
+ ):
156
+ raise InvalidCredentialsError
157
+
158
+ return True
159
+
160
+
161
+ class APIKeyAuthMixin(AuthSchemeMixin):
162
+ """
163
+ API Key authentication scheme mixin.
164
+
165
+ Provides an implementation of the authenticate method for API Key authentication.
166
+ """
167
+
168
+ API_KEY_SECRET_NAME = "simpleapi-api-key"
169
+
170
+ def authenticate(self, credentials: APIKeyCredentials) -> bool: # type: ignore[override]
171
+ """Authenticate the request."""
172
+ try:
173
+ api_key = self.secrets[self.API_KEY_SECRET_NAME]
174
+ except KeyError as error:
175
+ log.error(
176
+ f"SimpleAPI secret for API Key authentication is not set; please set a value for "
177
+ f"{self.API_KEY_SECRET_NAME}"
178
+ )
179
+ raise InvalidCredentialsError from error
180
+
181
+ if not compare_digest(credentials.key.encode(), api_key.encode()):
182
+ raise InvalidCredentialsError
183
+
184
+ return True