fastapi-aws 0.0.7__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.
@@ -0,0 +1,306 @@
1
+ from typing import Any, Dict, List
2
+ import json
3
+
4
+
5
+ from .route import register_integration
6
+
7
+
8
+ @register_integration("mock")
9
+ def mock_integration(
10
+ uri: str, iam_arn: str, path_parameters: List[str], **kwargs
11
+ ) -> Dict[str, Any]:
12
+ """returns a mock integration which has a fixed response value
13
+ NB: this function should take parameters for the fixed responses.
14
+ NB: this can currently be used for a 'not implemented' response.
15
+ """
16
+ return dict(
17
+ uri="",
18
+ integration_type="mock",
19
+ request_template={"statusCode": 200},
20
+ responses={
21
+ "default": {
22
+ "statusCode": 501,
23
+ "responseTemplates": {
24
+ "application/json": json.dumps({"status": "not implemented"})
25
+ },
26
+ }
27
+ },
28
+ )
29
+
30
+
31
+ @register_integration("aws_lambda_uri")
32
+ def lambda_integration(
33
+ uri: str,
34
+ iam_arn: str,
35
+ integration_type="aws_proxy",
36
+ path_parameters: List[str] = None,
37
+ request_template: str = None,
38
+ **kwargs,
39
+ ) -> Dict[str, Any]:
40
+ """returns an aws integration description for calling lambdas from apigw.
41
+
42
+ NB: this return value includes strings relating to resource arns in terraform,
43
+ so the apigw deployment must load this function output and replace these placeholders.
44
+
45
+ The return value should look like:
46
+ "x-amazon-apigateway-integration": {
47
+ "uri": "${lambda_function_arn}",
48
+ "httpMethod": "POST",
49
+ "type": "aws",
50
+ "credentials": "${lambda_function_iam_arn}",
51
+ "requestTemplates": {
52
+ "application/json": json.dumps({
53
+ "body": "$input.json('$')",
54
+ "httpMethod": "POST",
55
+ "resource": "/",
56
+ "path": "/"
57
+ })
58
+ }
59
+ }
60
+ There is also a "responses" key which we should set to reformat the output, but do not yet.
61
+ The optional "path_parameters" parameter is a list of variable path elements
62
+ which are added to the requestTemplate:
63
+ "application/json": json.dumps({
64
+ "body": "$input.json('$')",
65
+ "httpMethod": "POST",
66
+ "resource": "/",
67
+ "path": "/"
68
+ "pathParameters": {...}
69
+ })
70
+
71
+ MB: the format of this pathParameters string is important, see the code for details
72
+
73
+ NB: use "type": "aws" to pass request.body directory, or "aws_proxy" to get request context
74
+ """
75
+ if path_parameters:
76
+ if not isinstance(path_parameters, list):
77
+ raise ValueError("path_parameters must be a list of strings")
78
+
79
+ request_parameters = {
80
+ "integration.request.path.%s" % k: "method.request.path.%s" % k
81
+ for k in path_parameters
82
+ }
83
+ else:
84
+ request_parameters = None
85
+
86
+ if request_template:
87
+ request_template = {"application/json": request_template}
88
+
89
+ responses = {"default": {"statusCode": "200"}}
90
+
91
+ return dict(
92
+ uri=uri,
93
+ integration_type=integration_type,
94
+ http_method="POST",
95
+ credentials=iam_arn,
96
+ request_template=request_template,
97
+ request_parameters=request_parameters,
98
+ responses=responses,
99
+ )
100
+
101
+
102
+ def step_function_integration_base(
103
+ uri: str,
104
+ sfn_arn: str,
105
+ iam_arn: str,
106
+ mapping_template: Dict[str, str],
107
+ ) -> Dict[str, Any]:
108
+ """returns an aws integration for sync invocation of a step function from apigw.
109
+
110
+ NB: the input to the step function is always the json serialized body object.
111
+ we not **not** pass through any path parameters at the moment.
112
+
113
+ NB: this return value includes strings relating to resource arns in terraform,
114
+ so the apigw deployment must load this function output and replace these placeholders.
115
+
116
+ The return value should look like:
117
+ "x-amazon-apigateway-integration": {
118
+ "uri": "arn:aws:apigateway:${region}:states:action/StartSyncExecution",
119
+ "httpMethod": "POST",
120
+ "type": "aws",
121
+ "credentials": "${sfn_invoke_iam_role_arn}",
122
+ "requestTemplates": {
123
+ "application/json": json.dumps({
124
+ "input": "$util.escapeJavaScript($input.json(\'$\'))",
125
+ "stateMachineArn": "${step_function_arn}",
126
+ "region": "${region}"
127
+ })
128
+ },
129
+ "responses": {
130
+ "default": {
131
+ "statusCode": "200",
132
+ "responseTemplates": {
133
+ "application/json": "#set($output = $util.parseJson($input.path('$.output')))\n$output.body"
134
+ },
135
+ }
136
+ },
137
+ }
138
+ },
139
+ NB: the format of this pathParameters string is important, see the code for details
140
+ """
141
+ if mapping_template is None:
142
+ mapping_template = "$input.json('$')"
143
+ elif isinstance(mapping_template, dict):
144
+ mapping_template = json.dumps(mapping_template)
145
+
146
+ request_template = {
147
+ "application/json": json.dumps(
148
+ {
149
+ "input": mapping_template,
150
+ "stateMachineArn": sfn_arn,
151
+ "region": "${region}",
152
+ }
153
+ )
154
+ }
155
+
156
+ # FIXME: take response templates as parameters so we can handle errors nicely.
157
+ responses = {
158
+ "default": {
159
+ "statusCode": "200",
160
+ "responseTemplates": {
161
+ "application/json": "#set($output = $util.parseJson($input.path('$.output')))\n$output.body"
162
+ },
163
+ }
164
+ }
165
+
166
+ return dict(
167
+ uri=uri,
168
+ integration_type="aws",
169
+ http_method="POST",
170
+ credentials=iam_arn,
171
+ request_template=request_template,
172
+ responses=responses,
173
+ )
174
+
175
+
176
+ @register_integration("aws_sfn_sync_arn")
177
+ def step_function_sync_integration(
178
+ sfn_arn: str,
179
+ iam_arn: str,
180
+ path_parameters: List[str] = None,
181
+ mapping_template: dict = None,
182
+ **kwargs,
183
+ ) -> Dict[str, Any]:
184
+ """returns an aws integration for sync invocation of a step function from apigw."""
185
+
186
+ return step_function_integration_base(
187
+ "arn:aws:apigateway:${region}:states:action/StartSyncExecution",
188
+ sfn_arn,
189
+ iam_arn,
190
+ mapping_template,
191
+ )
192
+
193
+
194
+ @register_integration("aws_sfn_arn")
195
+ def step_function_integration(
196
+ sfn_arn: str,
197
+ iam_arn: str,
198
+ path_parameters: List[str] = None,
199
+ mapping_template: dict = None,
200
+ **kwargs,
201
+ ) -> Dict[str, Any]:
202
+ return step_function_integration_base(
203
+ "arn:aws:apigateway:${region}:states:action/StartExecution",
204
+ sfn_arn,
205
+ iam_arn,
206
+ mapping_template,
207
+ )
208
+
209
+
210
+ @register_integration("aws_s3_bucket")
211
+ def s3_integration(
212
+ bucket_name: str,
213
+ iam_arn: str,
214
+ path_parameters: List[str] = None,
215
+ http_method: str = "GET",
216
+ object_key: str = None,
217
+ **kwargs,
218
+ ) -> Dict[str, Any]:
219
+ """
220
+ Returns an AWS integration for S3 from API Gateway.
221
+
222
+ This allows API Gateway to interact with S3 objects via HTTP methods.
223
+
224
+ :param bucket_name: Name of the S3 bucket.
225
+ :param iam_arn: IAM role ARN to assume for the integration.
226
+ :param path_parameters: list of path parameters in the apigw request path; if `object_key` is None, the last value here is used as as the object key to lookup
227
+ :param http_method: HTTP method (GET, PUT, DELETE) to use for the integration.
228
+ :param object_key: [optional] fixed object key to return from the bucket
229
+
230
+ # TODO: path_parameters could be merged to allow multiple names
231
+ # TODO: object_key_prefix: allow a key prefix to be used for accessing objects by path parameter
232
+
233
+ Example OpenAPI integration:
234
+
235
+ "x-amazon-apigateway-integration": {
236
+ "uri": "arn:aws:apigateway:${region}:s3:path/{bucket}/{key}",
237
+ "httpMethod": "GET",
238
+ "type": "aws",
239
+ "credentials": "${iam_role_arn}",
240
+ "requestParameters": {
241
+ "integration.request.path.bucket": "bucket_name",
242
+ "integration.request.path.key": "method.request.path.key"
243
+ },
244
+ "responses": {
245
+ "default": {
246
+ "statusCode": "200",
247
+ "responseParameters": {
248
+ "method.response.header.Content-Type": "'application/octet-stream'"
249
+ }
250
+ }
251
+ }
252
+ }
253
+ """
254
+ assert http_method in (
255
+ "GET",
256
+ "PUT",
257
+ "DELETE",
258
+ ), "Invalid HTTP method for S3 integration"
259
+
260
+ # define the S3 integration URI using API Gateway's S3 service
261
+ # uri = f"arn:aws:apigateway:${{region}}:s3:path/{bucket_name}/{{key}}"
262
+ # FIXME: add the key parameter here to specify an object; but where should the value coem from?
263
+ # path_parameters? kwargs?
264
+ # NB: i think uri should be the bucket arn
265
+ uri = f"arn:aws:apigateway:${{region}}:s3:path/{bucket_name}"
266
+
267
+ if object_key:
268
+ uri = "/".join((uri, object_key))
269
+ elif path_parameters:
270
+ uri = "/".join([uri] + path_parameters)
271
+ else:
272
+ raise ValueError("expected one of: 'object_key', 'path_parameters'")
273
+
274
+ # apigw request parameters mapping
275
+ request_parameters = kwargs.get("request_parameters")
276
+
277
+ # response mapping (simple passthrough)
278
+ # FIXME: take the integration response content type to get the object content type
279
+ default_response_parameters = {
280
+ "default": {
281
+ "statusCode": "200",
282
+ # "responseParameters": {
283
+ # "method.response.header.Content-Type": "integration.response.header.Content-Type"
284
+ # },
285
+ },
286
+ "4xx": {"statusCode": "404"},
287
+ "403": {"statusCode": "404"},
288
+ "404": {"statusCode": "404"},
289
+ }
290
+
291
+ responses = kwargs.get("responses") or default_response_parameters
292
+
293
+ # generate apigw integration config
294
+ integration = dict(
295
+ uri=uri,
296
+ http_method=http_method,
297
+ integration_type="aws",
298
+ credentials=iam_arn,
299
+ request_parameters=request_parameters,
300
+ responses=responses,
301
+ )
302
+
303
+ if request_parameters:
304
+ integration["request_parameters"] = request_parameters
305
+
306
+ return integration
fastapi_aws/models.py ADDED
@@ -0,0 +1,57 @@
1
+ """Pydantic models for the AWS API integration validation
2
+ """
3
+ class IntegrationType(str, Enum):
4
+ AWS_PROXY = "aws_proxy"
5
+ AWS = "aws"
6
+ MOCK = "mock"
7
+
8
+
9
+ class Integration(BaseModel):
10
+ """Baseclass for defining integrations
11
+ TODO: build the integrations with classes and then export to dict.
12
+ """
13
+
14
+ type: IntegrationType
15
+ requestTemplates: Dict[str, Any]
16
+ responses: Dict[str, Any]
17
+
18
+ def model_dump(self) -> Dict[str, Any]:
19
+ return {"x-amazon-apigateway-integration": super().model_dump()}
20
+
21
+
22
+ class LambdaJSONRequestTemplate(BaseModel):
23
+ body: str
24
+ httpMethod: str = "POST"
25
+ resource: str = "/"
26
+ path: str = "/"
27
+ pathParameters: Optional[Dict[str, str]] = None
28
+
29
+
30
+ class MockIntegration(Integration):
31
+ type: IntegrationType = IntegrationType.MOCK
32
+
33
+
34
+ class ServiceIntegration(Integration):
35
+ uri: str = Field(..., description="arn of the called resource")
36
+ credentials: str = Field(..., description="arn of the IAM used to invoke the resource")
37
+ httpMethod: str = "POST"
38
+
39
+
40
+ class LambdaIntegration(ServiceIntegration):
41
+ type: IntegrationType = IntegrationType.AWS_PROXY
42
+ # uri must be lambda arn
43
+
44
+
45
+ class StepFunctionIntegration(ServiceIntegration):
46
+ type: IntegrationType = IntegrationType.AWS
47
+ # uri: "arn:aws:apigateway:${region}:states:action/StartSyncExecution"
48
+
49
+
50
+ class NotImplementedIntegration(MockIntegration):
51
+ requestTemplates: Dict[str, Any] = {"application/json": {"statusCode": 200}}
52
+ responses: Dict[str, Any] = {
53
+ "default": {
54
+ "statusCode": "200",
55
+ "responseTemplates": {"application/json": '{"status": "not implemented"}'},
56
+ }
57
+ }
fastapi_aws/route.py ADDED
@@ -0,0 +1,191 @@
1
+ from fastapi.routing import APIRoute
2
+ from typing import Any, Callable, List
3
+ from string import Formatter
4
+
5
+
6
+ def register_integration(service_name: str):
7
+ """Decorator to register integration methods dynamically in AWSAPIRoute"""
8
+
9
+ def decorator(func):
10
+ if not hasattr(AWSAPIRoute, "_integration_registry"):
11
+ AWSAPIRoute._integration_registry = {}
12
+ AWSAPIRoute._integration_registry[service_name] = func
13
+ return func
14
+
15
+ return decorator
16
+
17
+
18
+ class AWSAPIRoute(APIRoute):
19
+ """fastapi.apiroute derived class adapted for aws service integrations
20
+ This will provide an openapi json description of an aws service integration
21
+ for a route, allowing the openapi spec to be uploaded as an aws apigw rest
22
+ service automativicly.
23
+ """
24
+
25
+ _integration_registry = {} # registered service integrations
26
+
27
+ def __init__(self, path: str, endpoint: Callable[..., Any], **kwargs: Any):
28
+ """overload the APIRoute constructor
29
+
30
+ This has to happen because we cannot just pass kwargs around fastapi.
31
+ Probably for code highlighting or smth but the internal fastapi include_router
32
+ functions copy objects by explicity listing all the fields of the objects, so
33
+ our derived class cannot have custom fields and use the app or router functions.
34
+
35
+ Futhermore, the super-constructor removes or resets fields when it is called.
36
+
37
+ very frustrating, up yours fastapi
38
+ """
39
+ # validate the route args contain an aws service entry for which we have an integration
40
+ # and an entry for 'aws_iam_arn' for permission to call that service.
41
+ selected_services = set(self._integration_registry).intersection(set(kwargs))
42
+
43
+ if not selected_services:
44
+ # no aws integration in this route, so just call super
45
+ return super().__init__(path, endpoint, **kwargs)
46
+
47
+ if len(selected_services) > 1:
48
+ raise ValueError(
49
+ f"Exactly one of {self._integration_registry.keys()} is required, but found {selected_services} in {kwargs.keys()}"
50
+ )
51
+
52
+ # pop the params from the kwargs to stop fastapi spazzing out
53
+ aws_service_name = selected_services.pop()
54
+ aws_service_value = kwargs.pop(aws_service_name)
55
+ aws_object_key = kwargs.pop("aws_object_key", None)
56
+
57
+ aws_iam_arn = kwargs.pop("aws_iam_arn", None)
58
+ if not aws_iam_arn:
59
+ raise ValueError("'aws_iam_arn' is required for AWS integrations.")
60
+
61
+ # extract the path parmaeters
62
+ if hasattr(self, "path_format"):
63
+ path_parameters = self._extract_path_parameters(self.path_format)
64
+ else:
65
+ path_parameters = []
66
+
67
+ # extract the mapping templates if present
68
+ aws_request_template = kwargs.pop("request_template", None)
69
+ aws_request_parameters = kwargs.pop("request_parameters", None)
70
+ aws_mapping_template = kwargs.pop("aws_mapping_template", None)
71
+ aws_response_template = kwargs.pop("response_template", None)
72
+
73
+ # this is hack because fastapi does explict member copies resulting in duplicate
74
+ # objects rather than copy-by-reference (which i expect).
75
+ self.openapi_extra = kwargs.pop("openapi_extra", {})
76
+
77
+ # APIRoute clears openapi_extra here if it is set, so preseve it before calling the super constructor
78
+ if "x-amazon-apigateway-integration" in self.openapi_extra:
79
+ integration = self.openapi_extra
80
+ else:
81
+ integration = None
82
+
83
+ super().__init__(path, endpoint, **kwargs)
84
+
85
+ # if we already have the x-int, it has been copied over the super constructor
86
+ # so do not build a new integration
87
+ if not integration:
88
+ # find the integration which matches one of our kwargs
89
+ integration_fn = self._integration_registry[aws_service_name]
90
+
91
+ integration_params = integration_fn(
92
+ aws_service_value,
93
+ iam_arn=aws_iam_arn,
94
+ path_parameters=path_parameters,
95
+ mapping_template=aws_mapping_template,
96
+ request_template=aws_request_template,
97
+ request_parameters=aws_request_parameters,
98
+ response_template=aws_response_template,
99
+ object_key=aws_object_key, # NB: s3 only
100
+ http_method="GET" if "GET" in self.methods else next(iter(self.methods)),
101
+ )
102
+
103
+ integration = self._create_integration(**integration_params)
104
+
105
+ if self.openapi_extra is None:
106
+ self.openapi_extra = {}
107
+
108
+ self.openapi_extra.update(integration)
109
+
110
+ @staticmethod
111
+ def _extract_path_parameters(path: str) -> List[str]:
112
+ formatter = Formatter()
113
+ return [fname for _, fname, _, _ in formatter.parse(path) if fname]
114
+
115
+ def _create_integration(
116
+ self,
117
+ uri: str,
118
+ integration_type: str,
119
+ credentials: str,
120
+ responses: dict = None,
121
+ request_template: dict = None,
122
+ request_parameters: dict = None,
123
+ http_method: str = "POST",
124
+ ):
125
+ """create the x-amazon-apigateway-integration block for the openapi spec
126
+
127
+ This block defines how a request is made to the backend function so is always a POST request
128
+
129
+ NB: uri is not required for mock integrations, so it should be optional
130
+
131
+ request_template must be a dict of mimetype->string
132
+ request_parameters map request (HTTP) to integration parameter
133
+ responses is a dict of integration response patterns (key) to output transform
134
+
135
+ see:
136
+ + general: https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-swagger-extensions.html
137
+ + request_templates: https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-swagger-extensions-integration-requestTemplates.html
138
+ + request_parameters: https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-swagger-extensions-integration-requestParameters.html
139
+ + responses: https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-swagger-extensions-integration-response.html
140
+ """
141
+ assert integration_type in ("aws", "aws_proxy")
142
+
143
+ assert isinstance(responses, dict), "responses must be a dict [%s]" % str(
144
+ type(responses)
145
+ )
146
+ assert (
147
+ "default" in responses
148
+ ), "expected 'default' in responses (recieved: '%s')" % str(
149
+ list(responses.keys())
150
+ )
151
+
152
+ integration = {
153
+ "uri": uri,
154
+ "httpMethod": http_method,
155
+ "type": integration_type,
156
+ "credentials": credentials,
157
+ "responses": responses,
158
+ }
159
+
160
+ if request_template is not None:
161
+ # request templates must be a dict of mimetypes to strings
162
+ # https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-swagger-extensions-integration-requestTemplates.html
163
+ assert isinstance(
164
+ request_template, dict
165
+ ), "request_template must be dict [%s]" % (str(type(request_template)))
166
+
167
+ request_template_mimetypes = ("application/json", "application/xml")
168
+ assert all(
169
+ x_ in request_template_mimetypes for x_ in request_template
170
+ ), "only mimetypes are expected in request_templates (found: '%s')" % str(
171
+ list(request_template.keys())
172
+ )
173
+ assert all(
174
+ isinstance(x_, str) for x_ in request_template
175
+ ), "all request templates must be strings (found: '%s')" % (
176
+ str([str(type(x)) for x in request_template.values()])
177
+ )
178
+
179
+ integration["requestTemplates"] = request_template
180
+
181
+ if request_parameters is not None:
182
+ # TODO: validate against: https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-swagger-extensions-integration-requestParameters.html
183
+ assert isinstance(
184
+ request_parameters, dict
185
+ ), "request_parameters must be dict [%s]" % str(type(request_parameters))
186
+ integration["requestParameters"] = request_parameters
187
+
188
+ return {"x-amazon-apigateway-integration": integration}
189
+
190
+
191
+ from .integrations import *
fastapi_aws/router.py ADDED
@@ -0,0 +1,45 @@
1
+ """AWSAPIRouter
2
+
3
+ inherited from APIRouter so we can pass kwargs to get, post, put, delete decorators
4
+ """
5
+ from fastapi import APIRouter
6
+ from fastapi.types import DecoratedCallable
7
+ from typing import Any, Callable, Optional, Type
8
+
9
+
10
+ from .route import AWSAPIRoute
11
+
12
+
13
+ class AWSAPIRouter(APIRouter):
14
+ def __init__(self, *args, route_class: Type[AWSAPIRoute] = AWSAPIRoute, **kwargs):
15
+ super().__init__(*args, route_class=route_class, **kwargs)
16
+
17
+ def add_api_route(
18
+ self,
19
+ path: str,
20
+ endpoint: Callable[..., Any],
21
+ **kwargs: Any
22
+ ) -> None:
23
+ route_class = kwargs.pop("route_class_override", None) or self.route_class
24
+ route = route_class(path, endpoint, **kwargs)
25
+ self.routes.append(route)
26
+
27
+ def api_route(self, path: str, **kwargs: Any) -> Callable[[DecoratedCallable], DecoratedCallable]:
28
+ def decorator(func: DecoratedCallable) -> DecoratedCallable:
29
+ self.add_api_route(path, func, **kwargs)
30
+ return func
31
+
32
+ return decorator
33
+
34
+ # Override the HTTP method decorators to accept aws_lambda_uri
35
+ def get(self, path: str, **kwargs: Any):
36
+ return self.api_route(path, methods=["GET"], **kwargs)
37
+
38
+ def post(self, path: str, **kwargs: Any):
39
+ return self.api_route(path, methods=["POST"], **kwargs)
40
+
41
+ def put(self, path: str, **kwargs: Any):
42
+ return self.api_route(path, methods=["PUT"], **kwargs)
43
+
44
+ def delete(self, path: str, **kwargs: Any):
45
+ return self.api_route(path, methods=["DELETE"], **kwargs)