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,7 @@
1
+ from ._version import version as __version__
2
+
3
+ from .route import AWSAPIRoute
4
+ from .router import AWSAPIRouter
5
+ from .authorizers import CognitoAuthorizer, APIKeyAuthorizer, LambdaAuthorizer
6
+
7
+ __all__ = ["AWSAPIRoute", "AWSAPIRouter", "CognitoAuthorizer", "APIKeyAuthorizer", "LambdaAuthorizer"]
@@ -0,0 +1,342 @@
1
+ """export the openapi.json to a given directory
2
+
3
+ This script loads a fastapi.router from the --router parameter and creates two openapi specs:
4
+ + public for sharing with public consumers of the api.
5
+ + private with CORS and integration definitions for the aws apigateway to consume.
6
+ """
7
+ import sys
8
+ import os
9
+ import argparse
10
+ import json
11
+ import fnmatch
12
+
13
+ from uvicorn.importer import import_from_string
14
+
15
+ from fastapi import FastAPI, Request, Response, APIRouter, Depends, Header
16
+ from fastapi.routing import APIRoute
17
+ from fastapi.responses import JSONResponse
18
+ from fastapi.openapi.utils import get_openapi
19
+
20
+ # from fastapi.middleware.cors import CORSMiddleware
21
+
22
+ OPENAPI_VERSION = os.getenv("OPENAPI_VERSION", "3.0.1")
23
+ CORS_ORIGINS = os.getenv("CORS_ORIGINS", "*").split(",")
24
+ CORS_HEADERS = os.getenv("CORS_HEADERS", "Content-Type,Authorization").split(",")
25
+ CORS_METHODS = os.getenv("CORS_METHODS", "*").split(",")
26
+
27
+
28
+ def cors_origins() -> str:
29
+ """format the cors origins for apigw
30
+ NB: we only really allow the wildcard or a single origin, so not sure what use this is
31
+ """
32
+ return "'%s'" % ",".join(CORS_ORIGINS)
33
+
34
+
35
+ def cors_headers() -> str:
36
+ """format the cors headers for apigw"""
37
+ return "'%s'" % ",".join(CORS_HEADERS)
38
+
39
+
40
+ def cors_methods() -> str:
41
+ """format the cors methods for apigw"""
42
+ # return "'%s'" % ",".join(CORS_METHODS)
43
+ return "'OPTIONS,GET,POST,DELETE'"
44
+
45
+
46
+ def add_cors_headers(response: Response):
47
+ """add CORS headers to response objects for fastapi"""
48
+ response.headers["Access-Control-Allow-Origin"] = cors_origins()
49
+ response.headers["Access-Control-Allow-Headers"] = cors_headers()
50
+ response.headers["Access-Control-Allow-Methods"] = cors_methods()
51
+ return response
52
+
53
+
54
+ def cors_headers_dependency(
55
+ response: Response,
56
+ access_control_allow_origin: str = Header(),
57
+ access_control_allow_headers: str = Header(),
58
+ access_control_allow_methods: str = Header(),
59
+ ):
60
+ return add_cors_headers(response)
61
+
62
+
63
+ def add_cors_dependency_to_router(router):
64
+ """Add CORS headers dependency to all routes in the router"""
65
+ for route in router.routes:
66
+ if any(x in route.methods for x in CORS_METHODS):
67
+ route.dependencies.append(Depends(cors_headers_dependency))
68
+
69
+
70
+ def cors_headers_schema():
71
+ return {
72
+ "Access-Control-Allow-Origin": {"schema": {"type": "string"}},
73
+ "Access-Control-Allow-Methods": {"schema": {"type": "string"}},
74
+ "Access-Control-Allow-Headers": {"schema": {"type": "string"}},
75
+ }
76
+
77
+
78
+ def cors_response_defaults():
79
+ return {
80
+ "method.response.header.Access-Control-Allow-Methods": cors_methods(),
81
+ "method.response.header.Access-Control-Allow-Headers": cors_headers(),
82
+ "method.response.header.Access-Control-Allow-Origin": cors_origins(),
83
+ }
84
+
85
+
86
+ def add_cors_preflight_routes(app: FastAPI):
87
+ """automatically add OPTIONS routes for CORS
88
+
89
+ These are require for the apigw spec, otherwise CORS requests fails.
90
+
91
+ NB: these preflight routes are added via fastapi, but we could do directly via schema modification
92
+ """
93
+ opt_router = APIRouter(dependencies=[Depends(cors_headers)])
94
+ rts = [r for r in app.routes if isinstance(r, APIRoute)]
95
+ for route in rts:
96
+ print("route: '%s'" % str(route))
97
+
98
+ async def options_handler(request: Request):
99
+ return add_cors_headers(JSONResponse(content={}))
100
+
101
+ opt_router.add_api_route(
102
+ path=route.path,
103
+ endpoint=options_handler,
104
+ methods=["OPTIONS"],
105
+ # tags=route.tags if route.tags else None,
106
+ # summary=f"Options for {route.summary}" if route.summary else None,
107
+ # include_in_schema=False,
108
+ tags=(route.tags or []) + ["CORS"],
109
+ responses={
110
+ "200": {"description": "200 response", "headers": cors_headers_schema()}
111
+ },
112
+ openapi_extra={
113
+ "x-amazon-apigateway-integration": {
114
+ "responses": {
115
+ "default": {
116
+ "statusCode": "200",
117
+ "responseParameters": cors_response_defaults(),
118
+ }
119
+ },
120
+ "passthroughBehavior": "when_no_match",
121
+ "timeoutInMillis": 29000,
122
+ "requestTemplates": {
123
+ "application/json": json.dumps({"statusCode": 200})
124
+ },
125
+ "type": "mock",
126
+ }
127
+ },
128
+ )
129
+
130
+ return opt_router
131
+
132
+
133
+ def inject_cors_headers(openapi_schema):
134
+ """Inject CORS headers into all responses in the OpenAPI schema.
135
+
136
+ This ensures that all responses (200, 4xx, 5xx, etc.) include:
137
+ - Access-Control-Allow-Origin
138
+ - Access-Control-Allow-Headers
139
+ - Access-Control-Allow-Methods
140
+
141
+ Required for AWS API Gateway to properly handle CORS for REST APIs.
142
+
143
+ Args:
144
+ openapi_schema (dict): The OpenAPI JSON schema.
145
+
146
+ Returns:
147
+ dict: Updated OpenAPI schema with CORS headers injected.
148
+ """
149
+ cors_headers = cors_headers_schema()
150
+ cors_response_parameters = cors_response_defaults()
151
+
152
+ for path, methods in openapi_schema.get("paths", {}).items():
153
+ for method, details in methods.items():
154
+ # Skip OPTIONS, as it's already handled by add_cors_preflight_routes()
155
+ if method.upper() == "OPTIONS":
156
+ continue
157
+
158
+ # Ensure responses exist
159
+ if "responses" not in details:
160
+ details["responses"] = {}
161
+
162
+ # Iterate through each response (e.g., 200, 400, 500)
163
+ for status_code, response in details["responses"].items():
164
+ if "headers" not in response:
165
+ response["headers"] = {}
166
+
167
+ # Inject CORS headers into each response
168
+ response["headers"].update(cors_headers)
169
+
170
+ # Ensure x-amazon-apigateway-integration exists
171
+ if "x-amazon-apigateway-integration" in details:
172
+ integration = details["x-amazon-apigateway-integration"]
173
+
174
+ if "responses" in integration:
175
+ for response_key, integration_response in integration[
176
+ "responses"
177
+ ].items():
178
+ if "responseParameters" not in integration_response:
179
+ integration_response["responseParameters"] = {}
180
+
181
+ # Inject responseParameters into API Gateway integration responses
182
+ integration_response["responseParameters"].update(
183
+ cors_response_parameters
184
+ )
185
+
186
+ return openapi_schema
187
+
188
+
189
+ def remove_keys_by_pattern(obj, pattern):
190
+ """Recursively remove keys from a dict matching the given pattern."""
191
+ if isinstance(obj, dict):
192
+ keys_to_delete = [key for key in obj if fnmatch.fnmatch(key, pattern)]
193
+ for key in keys_to_delete:
194
+ del obj[key]
195
+ for value in obj.values():
196
+ remove_keys_by_pattern(value, pattern)
197
+ elif isinstance(obj, list):
198
+ for item in obj:
199
+ remove_keys_by_pattern(item, pattern)
200
+
201
+
202
+ def make_public_api_schema(openapi_schema):
203
+ """Create a public version of the API schema with private data scrubbed.
204
+
205
+ This will match for any "x-amazon-apigateway-*" pattern.
206
+ Including:
207
+ + x-amazon-apigateway-integration
208
+ + x-amazon-apigateway-authtype
209
+ + x-amazon-apigateway-authorizer
210
+ """
211
+ remove_keys_by_pattern(openapi_schema, "x-amazon-apigateway-*")
212
+ return openapi_schema
213
+
214
+
215
+ def default_cors_headers(define_cors=True):
216
+ headers = {
217
+ "allowOrigins": "'*'",
218
+ "allowMethods": ["'%s'" % x for x in CORS_METHODS],
219
+ "allowHeaders": ["'%s'" % x for x in CORS_HEADERS],
220
+ }
221
+
222
+ return {"x-amazon-apigateway-cors": headers}
223
+
224
+
225
+ def aws_gateway_responses(define_cors=True):
226
+ """Add custom apigw responses to the openapi schema
227
+ see: https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-swagger-extensions-gateway-responses.html
228
+ """
229
+ default_cors = {
230
+ "gatewayresponse.header.Access-Control-Allow-Origin": cors_origins(),
231
+ "gatewayresponse.header.Access-Control-Allow-Methods": cors_methods(),
232
+ "gatewayresponse.header.Access-Control-Allow-Headers": cors_headers(),
233
+ }
234
+
235
+ responses = {
236
+ "DEFAULT_4XX": {
237
+ "statusCode": 400,
238
+ "responseTemplates": {
239
+ "application/json": json.dumps({"message": "Client error occurred"})
240
+ },
241
+ },
242
+ "DEFAULT_5XX": {
243
+ "statusCode": 500,
244
+ "responseTemplates": {
245
+ "application/json": json.dumps({"message": "Internal server error"})
246
+ },
247
+ },
248
+ "ACCESS_DENIED": {
249
+ "statusCode": 403,
250
+ "responseTemplates": {
251
+ "application/json": json.dumps({"message": "Access Denied"})
252
+ },
253
+ },
254
+ "UNAUTHORIZED": {
255
+ "statusCode": 401,
256
+ "responseTemplates": {
257
+ "application/json": json.dumps({"message": "Unauthorized"})
258
+ },
259
+ },
260
+ "MISSING_AUTHENTICATION_TOKEN": {
261
+ "statusCode": 404,
262
+ "responseTemplates": {
263
+ "application/json": json.dumps({"message": "Route not found"})
264
+ },
265
+ },
266
+ }
267
+
268
+ if define_cors:
269
+ for k in responses:
270
+ responses[k].update({"responseParameters": default_cors})
271
+
272
+ return {"x-amazon-apigateway-gateway-responses": responses}
273
+
274
+
275
+ if __name__ == "__main__":
276
+ parser = argparse.ArgumentParser()
277
+ parser.add_argument(
278
+ "app", help="app import string. e.g. 'main:app'", default="main:app"
279
+ )
280
+ parser.add_argument("--router", help="router import string", default=None)
281
+ parser.add_argument("-t", "--title", help="title of the API", default="untitled")
282
+ parser.add_argument("-v", "--version", help="version of the API", default="0.0.1")
283
+ parser.add_argument(
284
+ "--out-public",
285
+ help="public openapi definition (x-integration information removed)",
286
+ default="-",
287
+ )
288
+ parser.add_argument(
289
+ "--out-private",
290
+ help="openapi filename with x-integration information",
291
+ default=None,
292
+ )
293
+ parser.add_argument(
294
+ "--cors",
295
+ default=True,
296
+ action="store_true",
297
+ help="include CORS methods and resources for pre-flight responses",
298
+ )
299
+
300
+ args = parser.parse_args()
301
+
302
+ print(f"importing app from {args.app}")
303
+ router = import_from_string(args.router)
304
+ print(f"imported router: '{type(router)}'")
305
+
306
+ if router is None:
307
+ print("ERR: must include a router")
308
+ sys.exit(1)
309
+
310
+ app = FastAPI(default_route_class=type(router))
311
+ app.router = router
312
+
313
+ # print(app.routes)
314
+
315
+ if args.cors:
316
+ app.include_router(add_cors_preflight_routes(app))
317
+
318
+ # print(app.routes)
319
+
320
+ openapi_schema = get_openapi(
321
+ title=args.title,
322
+ version=args.version,
323
+ openapi_version=OPENAPI_VERSION,
324
+ routes=app.routes,
325
+ )
326
+
327
+ # if args.cors:
328
+ # add_cors_responses(openapi_schema)
329
+
330
+ openapi_schema.update(aws_gateway_responses(args.cors))
331
+ openapi_schema.update(default_cors_headers(args.cors))
332
+ openapi_schema = inject_cors_headers(openapi_schema)
333
+
334
+ # write the private api definition (wuth all x-amazon-apigateway-integration info)
335
+ private = openapi_schema
336
+ with open(args.out_private, "w") as f:
337
+ json.dump(private, f, indent=2)
338
+
339
+ # write the public api definition (with all x-amazon-apigateway-integration and cors data scrubbed)
340
+ public = make_public_api_schema(openapi_schema)
341
+ with open(args.out_public, "w") as f:
342
+ json.dump(public, f, indent=2)
@@ -0,0 +1,21 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = ["__version__", "__version_tuple__", "version", "version_tuple"]
5
+
6
+ TYPE_CHECKING = False
7
+ if TYPE_CHECKING:
8
+ from typing import Tuple
9
+ from typing import Union
10
+
11
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
12
+ else:
13
+ VERSION_TUPLE = object
14
+
15
+ version: str
16
+ __version__: str
17
+ __version_tuple__: VERSION_TUPLE
18
+ version_tuple: VERSION_TUPLE
19
+
20
+ __version__ = version = '0.0.7'
21
+ __version_tuple__ = version_tuple = (0, 0, 7)
@@ -0,0 +1,182 @@
1
+ """AWS APIGateway Authorizers defined for openapi spec
2
+
3
+ Apply these security schemas on routers and endpoints to export an openapi
4
+ spec with aws integrations.
5
+
6
+ TODO: aws can have lambda authorizers as request or token types; however, in
7
+ this implementation the APIKeyAuthorizer accepts token-type auth but
8
+ does not allow lambda definitions, and the lambda definition allows
9
+ lambda uri but only request-type auth. This is a limitation.
10
+
11
+ refs:
12
+ + https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-swagger-extensions-api-key-source.html
13
+ + https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-swagger-extensions-auth.html
14
+ + https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-swagger-extensions-authorizer.html
15
+ """
16
+ from fastapi import Request
17
+ from fastapi.security import HTTPBearer
18
+ from fastapi.openapi.models import APIKey
19
+
20
+
21
+ class AWSAuthorizer(HTTPBearer):
22
+ """Base class for all AWS authorizers
23
+
24
+ type: str, should be one of ("token", "request", "cognito_user_pools")
25
+
26
+ ref:
27
+ """
28
+
29
+ DEFAULT_HEADER_FIELDNAME = "Authorization"
30
+
31
+ def __init__(
32
+ self,
33
+ authorizer_name: str,
34
+ authorizer_type: str,
35
+ auto_error: bool = True,
36
+ header_names: list = None,
37
+ ttl: int = 0,
38
+ ):
39
+ self.scheme_name = authorizer_name
40
+ self.auto_error = auto_error
41
+ self.ttl = ttl
42
+
43
+ assert authorizer_type in ("token", "request", "cognito_user_pools")
44
+ self.authorizer_type = authorizer_type
45
+
46
+ if header_names is None:
47
+ self.header_names = [AWSAuthorizer.DEFAULT_HEADER_FIELDNAME]
48
+ elif not isinstance(header_names, list):
49
+ self.header_names = [header_names]
50
+ else:
51
+ self.header_names = header_names
52
+
53
+ self.model = self._create_model()
54
+
55
+ def _create_model(self):
56
+ raise NotImplementedError()
57
+
58
+ async def __call__(self, request: Request):
59
+ """this class does not do any actual auth"""
60
+ pass
61
+
62
+
63
+ class CognitoAuthorizer(AWSAuthorizer):
64
+ """Fake cognito authorizer security model.
65
+
66
+ NB: we only accept single user_pool_arn at the moment
67
+ """
68
+
69
+ DEFAULT_USER_POOL_ARN = "${cognito_user_pool_arn}"
70
+
71
+ def __init__(
72
+ self,
73
+ authorizer_name: str,
74
+ auto_error: bool = True,
75
+ user_pool_arn=None,
76
+ header_names=None,
77
+ ):
78
+ """Initialize with the authorizer name.
79
+
80
+ Args:
81
+ authorizer_name (str): The name of the Cognito authorizer on AWS.
82
+ """
83
+ self.user_pool_arn = user_pool_arn or CognitoAuthorizer.DEFAULT_USER_POOL_ARN
84
+
85
+ super().__init__(
86
+ authorizer_name,
87
+ "cognito_user_pools",
88
+ auto_error=auto_error,
89
+ header_names=header_names,
90
+ )
91
+
92
+ def _create_model(self):
93
+ return APIKey(
94
+ **{
95
+ "type": "apiKey",
96
+ "in": "header",
97
+ "name": self.scheme_name,
98
+ "x-amazon-apigateway-authtype": "cognito_user_pools",
99
+ "x-amazon-apigateway-authorizer": {
100
+ "type": self.authorizer_type,
101
+ "providerARNs": [self.user_pool_arn],
102
+ "authorizerResultTtlInSeconds": self.ttl,
103
+ },
104
+ }
105
+ )
106
+
107
+
108
+ class APIKeyAuthorizer(AWSAuthorizer):
109
+ """APIKey authorizers check the header field for a specific value.
110
+
111
+ TODO: x-amazon-apigateway-api-key-source implementation required.
112
+ https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-swagger-extensions-api-key-source.html
113
+ """
114
+
115
+ DEFAULT_HEADER_FIELD_NAME = "x-api-key"
116
+
117
+ def __init__(
118
+ self, *, authorizer_name: str, auto_error: bool = True, header_names: str = None
119
+ ):
120
+ super().__init__(
121
+ authorizer_name, "token", auto_error=auto_error, header_names=header_names
122
+ )
123
+
124
+ def _create_model(self):
125
+ raise NotImplementedError()
126
+
127
+
128
+ class LambdaAuthorizer(AWSAuthorizer):
129
+ """Lambda authorizers run custom authorization code
130
+
131
+ TODO: x-amazon-apigateway-api-key-source implementation required.
132
+ https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-swagger-extensions-api-key-source.html
133
+ """
134
+
135
+ def __init__(
136
+ self,
137
+ *,
138
+ authorizer_name: str,
139
+ auto_error: bool = True,
140
+ aws_lambda_uri: str = None,
141
+ aws_iam_role_arn: str = None,
142
+ **kwargs
143
+ ):
144
+ assert aws_lambda_uri is not None
145
+ assert aws_iam_role_arn is not None
146
+
147
+ self.aws_lambda_uri = aws_lambda_uri
148
+ self.aws_iam_role_arn = aws_iam_role_arn
149
+
150
+ super().__init__(authorizer_name, "request", auto_error=auto_error, **kwargs)
151
+
152
+ def _create_model(self):
153
+ authorizer_params = {
154
+ "type": self.authorizer_type,
155
+ "authorizerUri": self.aws_lambda_uri,
156
+ "authorizerCredentials": self.aws_iam_role_arn,
157
+ "identityValidationExpression": "^x-[a-z]+",
158
+ "authorizerResultTtlInSeconds": self.ttl,
159
+ }
160
+
161
+ if self.authorizer_type == "request":
162
+ assert (
163
+ self.header_names is not None
164
+ ), "header_names is required when authorizer_type is 'request'"
165
+
166
+ mappings = [
167
+ ".".join(("method", "request", "header", name))
168
+ for name in self.header_names
169
+ ]
170
+
171
+ authorizer_params.update({"identitySource": ", ".join(mappings)})
172
+ print("%s: identity_source: '%s'" % ("auth", str(authorizer_params)))
173
+
174
+ return APIKey(
175
+ **{
176
+ "type": "apiKey",
177
+ "name": self.scheme_name,
178
+ "in": "header",
179
+ "x-amazon-apigateway-authtype": "custom",
180
+ "x-amazon-apigateway-authorizer": authorizer_params,
181
+ }
182
+ )