bedrock.generators.generate-openapi-specification

This script generates the OpenAPI specification for the Bedrock application.

  1"""
  2This script generates the OpenAPI specification for the Bedrock application.
  3"""
  4import re
  5import sys
  6from enum import Enum
  7
  8import yaml
  9from sqlalchemy.orm import RelationshipProperty
 10
 11from bedrock._helpers.classes import find_all_attributes, find_all_methods
 12from bedrock._helpers.string import camelCase_to_snake_case
 13from bedrock.config import get_version
 14from bedrock.config._modules import _get_mappings
 15from bedrock.config.models import get_models
 16from bedrock.generators.introspection.decorators import get_function_decorators
 17from bedrock.generators._helpers.spec_generation_helpers import dump_dict_to_file, dump_file
 18from bedrock.meta.model import convert_model_to_schema, python_type_to_openapi_type, get_looping_properties
 19
 20BASE_PATH = None
 21APP_NAME = None
 22VERSION = None
 23ENVIRONMENTS = None
 24IS_DOCS_SPEC = None
 25mappings = None
 26models = None
 27OPENAPI_PATH = None
 28USE_ENVELOPED_SCHEMA = True
 29
 30
 31def generate_files():
 32    """
 33    The do-all-the-things method.
 34
 35    See `get_endpoint_info` and `get_endpoint_schemas`.
 36    """
 37    # Paths
 38    dump_file(OPENAPI_PATH, "errors.yml", """"400":
 39  description: Bad request
 40  content:
 41    application/json:
 42      schema:
 43        $ref: "../schemas/error.yml#/Error"
 44"401":
 45  description: Unauthorized
 46  content:
 47    application/json:
 48      schema:
 49        $ref: "../schemas/error.yml#/Error"
 50"403":
 51  description: Forbidden
 52  content:
 53    application/json:
 54      schema:
 55        $ref: "../schemas/error.yml#/Error"
 56"404":
 57  description: Not Found
 58  content:
 59    application/json:
 60      schema:
 61        $ref: "../schemas/error.yml#/Error"
 62"409":
 63  description: Conflict
 64  content:
 65    application/json:
 66      schema:
 67        $ref: "../schemas/error.yml#/Error"
 68""", "paths")
 69    paths = {}
 70    for key, obj in mappings.items():
 71        endpoint_file_name, content_dict = get_endpoint_info(key, obj, USE_ENVELOPED_SCHEMA)
 72        dump_dict_to_file(OPENAPI_PATH, endpoint_file_name, content_dict, "paths")
 73        paths[key] = {"$ref": f"paths/{endpoint_file_name}"}
 74
 75    # Schemas
 76    dump_file(OPENAPI_PATH, "error.yml", """Error:
 77  type: object
 78  properties:
 79    _errors:
 80      type: array
 81      items:
 82        type: object
 83        properties:
 84          code:
 85            type: string
 86          message:
 87            type: string
 88          exception:
 89            type: string""", "schemas")
 90    schemas = {}
 91    for model in models:
 92        model_name = model.__name__
 93        model_schema = convert_model_to_schema(model)
 94        dump_dict_to_file(OPENAPI_PATH, f"{model_name}.yml", model_schema, "schemas")
 95    for obj in mappings.values():
 96        for schema_name, schema_content in get_endpoint_schemas(obj).items():
 97            schemas[schema_name] = {"$ref": f"schemas/{schema_name}.yml#/{schema_name}"}
 98            dump_dict_to_file(OPENAPI_PATH, f"{schema_name}.yml", schema_content, "schemas")
 99
100    servers = [{
101        "description": f'{environment["name"]} server',
102        "url": f'https://{environment["host"]}'
103    } for environment in ENVIRONMENTS]
104
105    # Main
106    main_content = {
107        "openapi": "3.0.3",
108        "info": {
109            "version": VERSION,
110            "title": f"{APP_NAME} API",
111            "description": f"API Documentation for {APP_NAME}"
112        },
113        "servers": servers,
114        "components": {
115            "securitySchemes": {
116                "bearerAuth": {
117                    "type": "http",
118                    "scheme": "bearer",
119                    "bearerFormat": "JWT"
120                },
121                "apiKeyAuth": {
122                    "type": "apiKey",
123                    "in": "header",
124                    "name": "x-api-key"
125                }
126            },
127            "schemas": schemas
128        },
129        "paths": paths
130    }
131
132    dump_dict_to_file(OPENAPI_PATH, "main.yml", main_content)
133
134
135def get_endpoint_info(endpoint_path, endpoint_object, enveloped_schema: bool = True) -> tuple[str, dict]:
136    """
137    Generate the OpenAPI definition for a given endpoint.
138    :param endpoint_path: The endpoint path (e.g. `/countries/{{countryUuid}}/regions`)
139    :param endpoint_object: The endpoint instance (e.g. `RegionsEndpoint()`)
140    :param enveloped_schema: Whether to include the envelope header and change the returned schema to be in a response envelope
141    :return: A tuple with the yaml filename and the OpenAPI definition for the endpoint methods
142    """
143    file_name = get_endpoint_file_name(endpoint_path)
144    cls = endpoint_object.__class__
145    ending_param = cls().param
146    methods = sorted(m for m in find_all_methods(cls, with_parents=False)
147                     if (endpoint_path.endswith(ending_param) and m.endswith("_single"))
148                     or not endpoint_path.endswith(ending_param) and m.endswith("_global"))
149    if not IS_DOCS_SPEC:
150        options_method = auto_define_options(methods)
151        all_methods = [*methods, options_method]
152    else:
153        all_methods = methods
154    methods_dict = {}
155    for m in all_methods:
156        methods_dict.update(generate_endpoint_http_method(m, endpoint_object, enveloped_schema))
157    return file_name, methods_dict
158
159
160def get_endpoint_schemas(endpoint) -> dict:
161    """
162    Gets both request and response schemas.
163    :param endpoint: The endpoint instance
164    """
165    return {
166        **get_endpoint_schema(endpoint, "body"),
167        **get_endpoint_schema(endpoint, "return")
168    }
169
170
171def get_endpoint_schema(endpoint, schema_type) -> dict:
172    """
173    Get the schema for a given endpoint.
174
175    Checks whether the endpoint has custom schema, a related model or nothing at all.
176    :param endpoint: The endpoint instance
177    :param schema_type: Either `"body"` or `"return"` (for request body or responses respectively)
178    """
179    name = getattr(endpoint, f"_get_{schema_type}_schema_name")()
180    if getattr(endpoint, f"has_custom_{schema_type}_schema")():
181        endpoint_schema = getattr(endpoint, f"get_{schema_type}_schema")()
182        if isinstance(endpoint_schema, str):
183            return {name: yaml.load(endpoint_schema, yaml.CLoader)}
184        return {name: endpoint_schema}
185    if endpoint.related_model is None:
186        return {}
187    return {name: convert_model_to_schema(endpoint.related_model, name)}
188
189
190def apigateway_integration_block(lambda_arn_variable, endpoint, http_method) -> dict:
191    """
192    Returns the `x-amazon-apigateway-integration` block for a given lambda ARN variable.
193    :param lambda_arn_variable: The Lambda ARN variable, e.g. `cities_endpoint_lambda_arn`. It'll be used as a placeholder for terraform to then replace.
194    :param endpoint: The endpoint instance
195    :param http_method: The HTTP method (e.g. `GET`, `POST`, `PUT`, `DELETE`, etc.)
196    """
197    if IS_DOCS_SPEC:
198        return {}
199    http_method = http_method.upper()
200    integration_type = endpoint._get_integration_type(http_method)
201
202    apigateway_integration = {"x-amazon-apigateway-integration":
203        {
204            "type": integration_type,
205            "uri": f"arn:aws:apigateway:${{region}}:lambda:path/2015-03-31/functions/${{{lambda_arn_variable}}}/invocations",
206            "passthroughBehavior": "when_no_match",
207            "httpMethod": "POST",  # For Lambda function invocations, the value must be POST.
208            "timeoutInMillis": 29000,
209        }
210    }
211
212    if integration_type != "aws_proxy":
213        apigateway_integration["x-amazon-apigateway-integration"]["requestTemplates"] = {
214            f"{endpoint._get_request_content_type(http_method)}": f"{endpoint._get_request_mapping_template(http_method)}",
215        }
216        apigateway_integration["x-amazon-apigateway-integration"]["responses"] = {
217            f"{endpoint._get_response_mapping_regex__(http_method)}": {
218                "statusCode": "201",
219                "responseTemplates": {
220                    f"{endpoint._get_response_content_type__(http_method)}": f"{endpoint._get_response_mapping_template(http_method)}",
221                }
222            }
223        }
224        apigateway_integration["x-amazon-apigateway-integration"]["passthroughBehavior"] = "when_no_templates"
225
226    return apigateway_integration
227
228
229
230def get_model_properties_as_query_params(model_cls):
231    """
232    Get the model properties as query parameters.
233    :param model_cls: Class of the model
234    """
235    if issubclass(model_cls, Enum):
236        return {}
237    properties = [
238        getattr(model_cls, prop).prop for prop in find_all_attributes(model_cls) if prop not in ['metadata', 'registry']
239    ]
240    query_params = {}
241    for p in properties:
242        if isinstance(p, RelationshipProperty):
243            if p.key.endswith("s"):
244                query_params[f"{p.key}[empty]"] = {"type": "boolean", "example": False}
245        else:
246            data_type = python_type_to_openapi_type(p.columns[0].type.python_type, p.key, True)
247            if data_type:
248                query_params[f"{p.key}[OPER]"] = data_type
249    return query_params
250
251
252def get_endpoint_file_name(endpoint):
253    """
254    Get the filename for a given endpoint.
255    :param endpoint: The endpoint name
256    """
257    return endpoint[1:].replace("/", ".") \
258        .replace("{", "_") \
259        .replace("}", "_") + ".yml"
260
261
262def auto_define_options(methods):
263    """
264    Auto-define the `OPTIONS` method for a given endpoint (for CORS).
265    """
266    method = [m for m in methods if m.endswith("_single") or m.endswith("_global")][0]
267    return re.sub(r"\w+_", "options_", method)
268
269
270def generate_http_method_security(cls, method) -> dict:
271    """
272    Generate the security definition for a given method, based on whether it includes a `@protected` decorator.
273    :param cls: Endpoint class
274    :param method: Method name (e.g. `get_single`, `get_global`, `post_single`, `post_global`, etc.)
275    """
276    decorators = get_function_decorators(vars(cls)[method]) if "options" not in method else []
277    protected_decorators = [decorator for decorator in decorators if decorator.name == "@protected"]
278    if len(protected_decorators) > 0:
279        security_modes = [{"bearerAuth": []}]  # if using @protected, then it always has bearer auth
280        has_api_key_auth = len([True for decorator in protected_decorators if
281                                "keys" in decorator.kwargs or len(decorator.args) >= 3]) > 0
282        if has_api_key_auth and IS_DOCS_SPEC:
283            security_modes.append({"apiKeyAuth": []})
284        return {"security": security_modes}
285    return {}
286
287
288def generate_http_method_parameters(method: str, endpoint, enveloped_schema: bool = True) -> dict:
289    """
290    Generate the `path` and `query` parameters for a given method.
291    :param method: Name of the method (e.g. `get_single`, `get_global`, `post_single`, `post_global`, etc.)
292    :param endpoint: The endpoint instance
293    :param enveloped_schema: Whether the header should include the envelope toggle
294    :return: A dictionary `{"parameters": [...]}`.
295    """
296    cls = endpoint.__class__
297    is_global = "_global" in method
298
299    path_parameter_keys = endpoint.path_params if is_global else [*endpoint.path_params, endpoint.param_key]
300    path_params = [{
301        "in": "path",
302        "name": param,
303        "required": True,
304        "schema": {"type": "string"}
305    } for param in path_parameter_keys]
306
307    query_parameters = {}
308    if IS_DOCS_SPEC:
309        decorators = get_function_decorators(vars(cls)[method]) if "options" not in method else []
310        with_query_param_decorators = [decorator for decorator in decorators if decorator.name == "@with_query_param"]
311        for decorator in with_query_param_decorators:
312            query_parameters[decorator.args[0]] = python_type_to_openapi_type(decorator.args[1], decorator.args[0],
313                                                                              True)
314        filter_decorators = [decorator for decorator in decorators if decorator.name == "@filter_on_columns"]
315        if filter_decorators and endpoint.related_model:
316            query_parameters = {
317                **query_parameters,
318                **get_model_properties_as_query_params(endpoint.related_model)
319            }
320    query_params = [make_query_param(param, param_type) for param, param_type in query_parameters.items()]
321    header_params = [{
322        "in": "header",
323        "name": 'bedrock-response-envelope',
324        "required": False,
325        "schema": {"type": "string", "enum": ["true", "false"]}
326    }] if enveloped_schema else []
327    return {
328        "parameters": [*path_params, *query_params, *header_params]
329    }
330
331
332def make_query_param(param, param_type):
333    schema_info = {"schema": param_type}
334    if "examples" in param_type:
335        _param_type = param_type.copy()
336        del _param_type["examples"]
337        schema_info = {
338            "examples": param_type["examples"],
339            "schema": _param_type
340        }
341    return {
342        "in": "query",
343        "name": param,
344        "required": False,
345        **schema_info,
346        "allowReserved": True,
347        "description": "OPER can be one of `eq`, `ne`, `ge`, `gt`, `le`, `lt`, `in`, `ni`, `like`, `is`, `isnt`, `any` and `empty`" if "[OPER]" in param else ""
348    }
349
350
351def make_return_body_schema(endpoint):
352    body_schema_path = f"../schemas/{endpoint._get_return_schema_name()}.yml#/{endpoint._get_return_schema_name()}"
353    body_schema = {"$ref": body_schema_path}
354
355    if not endpoint.__return_schema_name__ and endpoint.related_model:
356        looping_properties = get_looping_properties(endpoint.related_model, as_camel_case=True)
357        body_schema = {
358            "allOf": [
359                body_schema,
360                {
361                    "properties": looping_properties
362                }
363            ]
364        }
365    return body_schema
366
367
368def generate_http_method_ok_response(method, endpoint, is_envelope: bool = True) -> dict:
369    """
370    Generate the success responses for a given method (i.e. `201` for create-type actions and `200` for everything else).
371    :param method: Name of the method (e.g. `get_single`, `get_global`, `post_single`, `post_global`, etc.)
372    :param endpoint: The endpoint instance
373    :param is_envelope: Whether the generated schema should be enveloped in a response object
374    :return: A dictionary, e.g. `{"200": {...}}`
375    """
376    is_global = "_global" in method
377    body_schema = make_return_body_schema(endpoint)
378    if not endpoint.__class__.has_custom_return_schema():
379        body_schema = {"allOf": [
380            {
381                "type": "object",
382                "properties": {
383                    "uuid": {
384                        "type": "string",
385                        "example": "43f5e9fd-1ffd-4469-8e01-f6d1b0b78d34"
386                    }
387                }
388            },
389            body_schema
390        ]}
391    if "delete" in method:
392        body_schema = {
393            "type": "object",
394            "properties": {
395                "deleted": {"type": "boolean"},
396                "originalData": body_schema
397            }
398        }
399    if is_global and endpoint.__class__.global_is_array():
400        body_schema = {
401            "type": "array",
402            "items": body_schema
403        }
404    http_code = "201" if "post" in method else "200"
405    description = "Created" if "post" in method else "Ok"
406    if "delete" in method:
407        enveloped_schema = {**body_schema}
408        enveloped_schema["properties"]["data"] = {"type": "object", "nullable": True}
409    else:
410        enveloped_schema = {
411            "type": "object",
412            "properties": {
413                "data": body_schema,
414                "_metadata": {
415                    "type": "object",
416                    "properties": {
417                        "pagination": {
418                            "type": "object",
419                            "properties": {
420                                "sortColumn": {"type": "string", "example": "created_at"},
421                                "sortOrder": {"type": "string", "enum": ["asc", "desc"], "example": "desc"},
422                                "limit": {"type": "integer", "example": 50, "nullable": True},
423                                "offset": {"type": "integer", "example": 10, "nullable": True},
424                                "total": {"type": "integer", "example": 1642, "nullable": True}
425                            },
426                            "nullable": True
427                        }
428                    }
429                },
430                "_warnings": {
431                    "type": "array",
432                    "items": {
433                        "type": "object",
434                        "properties": {
435                            "code": {"type": "string"},
436                            "message": {"type": "string"},
437                            "exception": {"type": "string"}
438                        }
439                    }
440                },
441                "_errors": {
442                    "type": "array",
443                    "items": {
444                        "type": "object",
445                        "properties": {
446                            "code": {"type": "string"},
447                            "message": {"type": "string"},
448                            "exception": {"type": "string"}
449                        }
450                    }
451                }
452            }
453        }
454    return {
455        http_code: {
456            "content": {"application/json": {"schema": enveloped_schema if is_envelope else body_schema}},
457            "description": description
458        }
459    }
460
461
462def generate_http_method_request_body(http_method, endpoint) -> dict:
463    """
464    Generate the request body for a given method (i.e. the body schema).
465    :param http_method: Name of the method (e.g. `get`, `post`, etc.)
466    :param endpoint: The endpoint instance
467    """
468    if http_method not in ["post", "put"]:
469        return {}
470    body_schema_path = f"../schemas/{endpoint._get_body_schema_name()}.yml#/{endpoint._get_body_schema_name()}"
471    body_schema = {"$ref": body_schema_path}
472    request_content_type = endpoint._get_request_content_type(http_method)
473    return {
474        "requestBody": {
475            "required": False,
476            "content": {request_content_type: {"schema": body_schema}}
477        }
478    }
479
480
481def http_method_summary(method, endpoint):
482    """
483    Generate the summary for a given method (i.e. "Get City list" or "Create Country")
484    :param method:
485    :param endpoint:
486    :return:
487    """
488    http_method = method.replace("_global", "").replace("_single", "")
489    is_global = "_global" in method and endpoint.__class__.global_is_array()
490    prefix_map = {
491        "get": "Get",
492        "post": "Create",
493        "put": "Update",
494        "delete": "Delete",
495        "options": "CORS for"
496    }
497    suffix_map = {
498        "get": " list" if is_global else "",
499        "post": "",
500        "put": "",
501        "delete": "",
502        "options": ""
503    }
504    return f"{prefix_map[http_method.lower()]} {endpoint.__class__.__name__}{suffix_map[http_method.lower()]}"
505
506
507def generate_endpoint_http_method(method: str, endpoint, enveloped_schema: bool = True) -> dict:
508    """
509    Generate the OpenAPI definition for a given endpoint method.
510    :param method: Name of the method (e.g. `get_single`, `get_global`, `post_single`, `post_global`, etc.)
511    :param endpoint: The endpoint instance
512    :param enveloped_schema: Whether to include the envelope header and change the returned schema to be in a response envelope
513    """
514    cls = endpoint.__class__
515    http_method = method.replace("_global", "").replace("_single", "")
516    lambda_arn_variable = f"{camelCase_to_snake_case(cls.__name__)}_lambda_arn"
517    return {
518        http_method: {
519            "summary": http_method_summary(method, endpoint),
520            "tags": [cls.__name__],
521            **generate_http_method_security(cls, method),
522            **generate_http_method_parameters(method, endpoint, enveloped_schema),
523            **generate_http_method_request_body(http_method, endpoint),
524            "responses": {
525                **generate_http_method_ok_response(method, endpoint, enveloped_schema),
526                "400": {"$ref": "./errors.yml#/400"},
527                "401": {"$ref": "./errors.yml#/401"},
528                "403": {"$ref": "./errors.yml#/403"},
529                "404": {"$ref": "./errors.yml#/404"},
530                "409": {"$ref": "./errors.yml#/409"}
531            },
532            **apigateway_integration_block(lambda_arn_variable, endpoint, http_method)
533        }
534    }
535
536
537if __name__ == '__main__':
538    BASE_PATH = sys.argv[1]
539    APP_NAME = sys.argv[2] if len(sys.argv) > 2 else "bedrock"
540    VERSION = sys.argv[3] if len(sys.argv) > 3 else get_version()
541    default_environments = [f"{env}|{APP_NAME}-api.{env}.keyholding.com" for env in ["testing", "staging"]]
542    default_environments.append(f"production|{APP_NAME}-api.keyholding.com")
543    environments_with_hosts = sys.argv[4].split(",") if len(sys.argv) > 4 else default_environments
544    ENVIRONMENTS = [{"name": env_with_host.split("|")[0], "host": env_with_host.split("|")[1]} for env_with_host in
545                    environments_with_hosts]
546    docs_param = sys.argv[5] if len(sys.argv) > 5 else "api-gw-spec"
547    IS_DOCS_SPEC = docs_param.lower() == "docs-spec"
548    if len(sys.argv) > 6 and sys.argv[6].lower() == "no-envelope":
549        USE_ENVELOPED_SCHEMA = False
550
551    mappings = _get_mappings(f"{BASE_PATH}/app/endpoints")
552    models = get_models(f"{BASE_PATH}/app/model", "model")
553    OPENAPI_PATH = f"{BASE_PATH}/openapi/"
554    generate_files()
BASE_PATH = None
APP_NAME = None
VERSION = None
ENVIRONMENTS = None
IS_DOCS_SPEC = None
mappings = None
models = None
OPENAPI_PATH = None
USE_ENVELOPED_SCHEMA = True
def generate_files():
 32def generate_files():
 33    """
 34    The do-all-the-things method.
 35
 36    See `get_endpoint_info` and `get_endpoint_schemas`.
 37    """
 38    # Paths
 39    dump_file(OPENAPI_PATH, "errors.yml", """"400":
 40  description: Bad request
 41  content:
 42    application/json:
 43      schema:
 44        $ref: "../schemas/error.yml#/Error"
 45"401":
 46  description: Unauthorized
 47  content:
 48    application/json:
 49      schema:
 50        $ref: "../schemas/error.yml#/Error"
 51"403":
 52  description: Forbidden
 53  content:
 54    application/json:
 55      schema:
 56        $ref: "../schemas/error.yml#/Error"
 57"404":
 58  description: Not Found
 59  content:
 60    application/json:
 61      schema:
 62        $ref: "../schemas/error.yml#/Error"
 63"409":
 64  description: Conflict
 65  content:
 66    application/json:
 67      schema:
 68        $ref: "../schemas/error.yml#/Error"
 69""", "paths")
 70    paths = {}
 71    for key, obj in mappings.items():
 72        endpoint_file_name, content_dict = get_endpoint_info(key, obj, USE_ENVELOPED_SCHEMA)
 73        dump_dict_to_file(OPENAPI_PATH, endpoint_file_name, content_dict, "paths")
 74        paths[key] = {"$ref": f"paths/{endpoint_file_name}"}
 75
 76    # Schemas
 77    dump_file(OPENAPI_PATH, "error.yml", """Error:
 78  type: object
 79  properties:
 80    _errors:
 81      type: array
 82      items:
 83        type: object
 84        properties:
 85          code:
 86            type: string
 87          message:
 88            type: string
 89          exception:
 90            type: string""", "schemas")
 91    schemas = {}
 92    for model in models:
 93        model_name = model.__name__
 94        model_schema = convert_model_to_schema(model)
 95        dump_dict_to_file(OPENAPI_PATH, f"{model_name}.yml", model_schema, "schemas")
 96    for obj in mappings.values():
 97        for schema_name, schema_content in get_endpoint_schemas(obj).items():
 98            schemas[schema_name] = {"$ref": f"schemas/{schema_name}.yml#/{schema_name}"}
 99            dump_dict_to_file(OPENAPI_PATH, f"{schema_name}.yml", schema_content, "schemas")
100
101    servers = [{
102        "description": f'{environment["name"]} server',
103        "url": f'https://{environment["host"]}'
104    } for environment in ENVIRONMENTS]
105
106    # Main
107    main_content = {
108        "openapi": "3.0.3",
109        "info": {
110            "version": VERSION,
111            "title": f"{APP_NAME} API",
112            "description": f"API Documentation for {APP_NAME}"
113        },
114        "servers": servers,
115        "components": {
116            "securitySchemes": {
117                "bearerAuth": {
118                    "type": "http",
119                    "scheme": "bearer",
120                    "bearerFormat": "JWT"
121                },
122                "apiKeyAuth": {
123                    "type": "apiKey",
124                    "in": "header",
125                    "name": "x-api-key"
126                }
127            },
128            "schemas": schemas
129        },
130        "paths": paths
131    }
132
133    dump_dict_to_file(OPENAPI_PATH, "main.yml", main_content)

The do-all-the-things method.

See get_endpoint_info and get_endpoint_schemas.

def get_endpoint_info( endpoint_path, endpoint_object, enveloped_schema: bool = True) -> tuple[str, dict]:
136def get_endpoint_info(endpoint_path, endpoint_object, enveloped_schema: bool = True) -> tuple[str, dict]:
137    """
138    Generate the OpenAPI definition for a given endpoint.
139    :param endpoint_path: The endpoint path (e.g. `/countries/{{countryUuid}}/regions`)
140    :param endpoint_object: The endpoint instance (e.g. `RegionsEndpoint()`)
141    :param enveloped_schema: Whether to include the envelope header and change the returned schema to be in a response envelope
142    :return: A tuple with the yaml filename and the OpenAPI definition for the endpoint methods
143    """
144    file_name = get_endpoint_file_name(endpoint_path)
145    cls = endpoint_object.__class__
146    ending_param = cls().param
147    methods = sorted(m for m in find_all_methods(cls, with_parents=False)
148                     if (endpoint_path.endswith(ending_param) and m.endswith("_single"))
149                     or not endpoint_path.endswith(ending_param) and m.endswith("_global"))
150    if not IS_DOCS_SPEC:
151        options_method = auto_define_options(methods)
152        all_methods = [*methods, options_method]
153    else:
154        all_methods = methods
155    methods_dict = {}
156    for m in all_methods:
157        methods_dict.update(generate_endpoint_http_method(m, endpoint_object, enveloped_schema))
158    return file_name, methods_dict

Generate the OpenAPI definition for a given endpoint.

Parameters
  • endpoint_path: The endpoint path (e.g. /countries/{{countryUuid}}/regions)
  • endpoint_object: The endpoint instance (e.g. RegionsEndpoint())
  • enveloped_schema: Whether to include the envelope header and change the returned schema to be in a response envelope
Returns

A tuple with the yaml filename and the OpenAPI definition for the endpoint methods

def get_endpoint_schemas(endpoint) -> dict:
161def get_endpoint_schemas(endpoint) -> dict:
162    """
163    Gets both request and response schemas.
164    :param endpoint: The endpoint instance
165    """
166    return {
167        **get_endpoint_schema(endpoint, "body"),
168        **get_endpoint_schema(endpoint, "return")
169    }

Gets both request and response schemas.

Parameters
  • endpoint: The endpoint instance
def get_endpoint_schema(endpoint, schema_type) -> dict:
172def get_endpoint_schema(endpoint, schema_type) -> dict:
173    """
174    Get the schema for a given endpoint.
175
176    Checks whether the endpoint has custom schema, a related model or nothing at all.
177    :param endpoint: The endpoint instance
178    :param schema_type: Either `"body"` or `"return"` (for request body or responses respectively)
179    """
180    name = getattr(endpoint, f"_get_{schema_type}_schema_name")()
181    if getattr(endpoint, f"has_custom_{schema_type}_schema")():
182        endpoint_schema = getattr(endpoint, f"get_{schema_type}_schema")()
183        if isinstance(endpoint_schema, str):
184            return {name: yaml.load(endpoint_schema, yaml.CLoader)}
185        return {name: endpoint_schema}
186    if endpoint.related_model is None:
187        return {}
188    return {name: convert_model_to_schema(endpoint.related_model, name)}

Get the schema for a given endpoint.

Checks whether the endpoint has custom schema, a related model or nothing at all.

Parameters
  • endpoint: The endpoint instance
  • schema_type: Either "body" or "return" (for request body or responses respectively)
def apigateway_integration_block(lambda_arn_variable, endpoint, http_method) -> dict:
191def apigateway_integration_block(lambda_arn_variable, endpoint, http_method) -> dict:
192    """
193    Returns the `x-amazon-apigateway-integration` block for a given lambda ARN variable.
194    :param lambda_arn_variable: The Lambda ARN variable, e.g. `cities_endpoint_lambda_arn`. It'll be used as a placeholder for terraform to then replace.
195    :param endpoint: The endpoint instance
196    :param http_method: The HTTP method (e.g. `GET`, `POST`, `PUT`, `DELETE`, etc.)
197    """
198    if IS_DOCS_SPEC:
199        return {}
200    http_method = http_method.upper()
201    integration_type = endpoint._get_integration_type(http_method)
202
203    apigateway_integration = {"x-amazon-apigateway-integration":
204        {
205            "type": integration_type,
206            "uri": f"arn:aws:apigateway:${{region}}:lambda:path/2015-03-31/functions/${{{lambda_arn_variable}}}/invocations",
207            "passthroughBehavior": "when_no_match",
208            "httpMethod": "POST",  # For Lambda function invocations, the value must be POST.
209            "timeoutInMillis": 29000,
210        }
211    }
212
213    if integration_type != "aws_proxy":
214        apigateway_integration["x-amazon-apigateway-integration"]["requestTemplates"] = {
215            f"{endpoint._get_request_content_type(http_method)}": f"{endpoint._get_request_mapping_template(http_method)}",
216        }
217        apigateway_integration["x-amazon-apigateway-integration"]["responses"] = {
218            f"{endpoint._get_response_mapping_regex__(http_method)}": {
219                "statusCode": "201",
220                "responseTemplates": {
221                    f"{endpoint._get_response_content_type__(http_method)}": f"{endpoint._get_response_mapping_template(http_method)}",
222                }
223            }
224        }
225        apigateway_integration["x-amazon-apigateway-integration"]["passthroughBehavior"] = "when_no_templates"
226
227    return apigateway_integration

Returns the x-amazon-apigateway-integration block for a given lambda ARN variable.

Parameters
  • lambda_arn_variable: The Lambda ARN variable, e.g. cities_endpoint_lambda_arn. It'll be used as a placeholder for terraform to then replace.
  • endpoint: The endpoint instance
  • http_method: The HTTP method (e.g. GET, POST, PUT, DELETE, etc.)
def get_model_properties_as_query_params(model_cls):
231def get_model_properties_as_query_params(model_cls):
232    """
233    Get the model properties as query parameters.
234    :param model_cls: Class of the model
235    """
236    if issubclass(model_cls, Enum):
237        return {}
238    properties = [
239        getattr(model_cls, prop).prop for prop in find_all_attributes(model_cls) if prop not in ['metadata', 'registry']
240    ]
241    query_params = {}
242    for p in properties:
243        if isinstance(p, RelationshipProperty):
244            if p.key.endswith("s"):
245                query_params[f"{p.key}[empty]"] = {"type": "boolean", "example": False}
246        else:
247            data_type = python_type_to_openapi_type(p.columns[0].type.python_type, p.key, True)
248            if data_type:
249                query_params[f"{p.key}[OPER]"] = data_type
250    return query_params

Get the model properties as query parameters.

Parameters
  • model_cls: Class of the model
def get_endpoint_file_name(endpoint):
253def get_endpoint_file_name(endpoint):
254    """
255    Get the filename for a given endpoint.
256    :param endpoint: The endpoint name
257    """
258    return endpoint[1:].replace("/", ".") \
259        .replace("{", "_") \
260        .replace("}", "_") + ".yml"

Get the filename for a given endpoint.

Parameters
  • endpoint: The endpoint name
def auto_define_options(methods):
263def auto_define_options(methods):
264    """
265    Auto-define the `OPTIONS` method for a given endpoint (for CORS).
266    """
267    method = [m for m in methods if m.endswith("_single") or m.endswith("_global")][0]
268    return re.sub(r"\w+_", "options_", method)

Auto-define the OPTIONS method for a given endpoint (for CORS).

def generate_http_method_security(cls, method) -> dict:
271def generate_http_method_security(cls, method) -> dict:
272    """
273    Generate the security definition for a given method, based on whether it includes a `@protected` decorator.
274    :param cls: Endpoint class
275    :param method: Method name (e.g. `get_single`, `get_global`, `post_single`, `post_global`, etc.)
276    """
277    decorators = get_function_decorators(vars(cls)[method]) if "options" not in method else []
278    protected_decorators = [decorator for decorator in decorators if decorator.name == "@protected"]
279    if len(protected_decorators) > 0:
280        security_modes = [{"bearerAuth": []}]  # if using @protected, then it always has bearer auth
281        has_api_key_auth = len([True for decorator in protected_decorators if
282                                "keys" in decorator.kwargs or len(decorator.args) >= 3]) > 0
283        if has_api_key_auth and IS_DOCS_SPEC:
284            security_modes.append({"apiKeyAuth": []})
285        return {"security": security_modes}
286    return {}

Generate the security definition for a given method, based on whether it includes a @protected decorator.

Parameters
  • cls: Endpoint class
  • method: Method name (e.g. get_single, get_global, post_single, post_global, etc.)
def generate_http_method_parameters(method: str, endpoint, enveloped_schema: bool = True) -> dict:
289def generate_http_method_parameters(method: str, endpoint, enveloped_schema: bool = True) -> dict:
290    """
291    Generate the `path` and `query` parameters for a given method.
292    :param method: Name of the method (e.g. `get_single`, `get_global`, `post_single`, `post_global`, etc.)
293    :param endpoint: The endpoint instance
294    :param enveloped_schema: Whether the header should include the envelope toggle
295    :return: A dictionary `{"parameters": [...]}`.
296    """
297    cls = endpoint.__class__
298    is_global = "_global" in method
299
300    path_parameter_keys = endpoint.path_params if is_global else [*endpoint.path_params, endpoint.param_key]
301    path_params = [{
302        "in": "path",
303        "name": param,
304        "required": True,
305        "schema": {"type": "string"}
306    } for param in path_parameter_keys]
307
308    query_parameters = {}
309    if IS_DOCS_SPEC:
310        decorators = get_function_decorators(vars(cls)[method]) if "options" not in method else []
311        with_query_param_decorators = [decorator for decorator in decorators if decorator.name == "@with_query_param"]
312        for decorator in with_query_param_decorators:
313            query_parameters[decorator.args[0]] = python_type_to_openapi_type(decorator.args[1], decorator.args[0],
314                                                                              True)
315        filter_decorators = [decorator for decorator in decorators if decorator.name == "@filter_on_columns"]
316        if filter_decorators and endpoint.related_model:
317            query_parameters = {
318                **query_parameters,
319                **get_model_properties_as_query_params(endpoint.related_model)
320            }
321    query_params = [make_query_param(param, param_type) for param, param_type in query_parameters.items()]
322    header_params = [{
323        "in": "header",
324        "name": 'bedrock-response-envelope',
325        "required": False,
326        "schema": {"type": "string", "enum": ["true", "false"]}
327    }] if enveloped_schema else []
328    return {
329        "parameters": [*path_params, *query_params, *header_params]
330    }

Generate the path and query parameters for a given method.

Parameters
  • method: Name of the method (e.g. get_single, get_global, post_single, post_global, etc.)
  • endpoint: The endpoint instance
  • enveloped_schema: Whether the header should include the envelope toggle
Returns

A dictionary {"parameters": [...]}.

def make_query_param(param, param_type):
333def make_query_param(param, param_type):
334    schema_info = {"schema": param_type}
335    if "examples" in param_type:
336        _param_type = param_type.copy()
337        del _param_type["examples"]
338        schema_info = {
339            "examples": param_type["examples"],
340            "schema": _param_type
341        }
342    return {
343        "in": "query",
344        "name": param,
345        "required": False,
346        **schema_info,
347        "allowReserved": True,
348        "description": "OPER can be one of `eq`, `ne`, `ge`, `gt`, `le`, `lt`, `in`, `ni`, `like`, `is`, `isnt`, `any` and `empty`" if "[OPER]" in param else ""
349    }
def make_return_body_schema(endpoint):
352def make_return_body_schema(endpoint):
353    body_schema_path = f"../schemas/{endpoint._get_return_schema_name()}.yml#/{endpoint._get_return_schema_name()}"
354    body_schema = {"$ref": body_schema_path}
355
356    if not endpoint.__return_schema_name__ and endpoint.related_model:
357        looping_properties = get_looping_properties(endpoint.related_model, as_camel_case=True)
358        body_schema = {
359            "allOf": [
360                body_schema,
361                {
362                    "properties": looping_properties
363                }
364            ]
365        }
366    return body_schema
def generate_http_method_ok_response(method, endpoint, is_envelope: bool = True) -> dict:
369def generate_http_method_ok_response(method, endpoint, is_envelope: bool = True) -> dict:
370    """
371    Generate the success responses for a given method (i.e. `201` for create-type actions and `200` for everything else).
372    :param method: Name of the method (e.g. `get_single`, `get_global`, `post_single`, `post_global`, etc.)
373    :param endpoint: The endpoint instance
374    :param is_envelope: Whether the generated schema should be enveloped in a response object
375    :return: A dictionary, e.g. `{"200": {...}}`
376    """
377    is_global = "_global" in method
378    body_schema = make_return_body_schema(endpoint)
379    if not endpoint.__class__.has_custom_return_schema():
380        body_schema = {"allOf": [
381            {
382                "type": "object",
383                "properties": {
384                    "uuid": {
385                        "type": "string",
386                        "example": "43f5e9fd-1ffd-4469-8e01-f6d1b0b78d34"
387                    }
388                }
389            },
390            body_schema
391        ]}
392    if "delete" in method:
393        body_schema = {
394            "type": "object",
395            "properties": {
396                "deleted": {"type": "boolean"},
397                "originalData": body_schema
398            }
399        }
400    if is_global and endpoint.__class__.global_is_array():
401        body_schema = {
402            "type": "array",
403            "items": body_schema
404        }
405    http_code = "201" if "post" in method else "200"
406    description = "Created" if "post" in method else "Ok"
407    if "delete" in method:
408        enveloped_schema = {**body_schema}
409        enveloped_schema["properties"]["data"] = {"type": "object", "nullable": True}
410    else:
411        enveloped_schema = {
412            "type": "object",
413            "properties": {
414                "data": body_schema,
415                "_metadata": {
416                    "type": "object",
417                    "properties": {
418                        "pagination": {
419                            "type": "object",
420                            "properties": {
421                                "sortColumn": {"type": "string", "example": "created_at"},
422                                "sortOrder": {"type": "string", "enum": ["asc", "desc"], "example": "desc"},
423                                "limit": {"type": "integer", "example": 50, "nullable": True},
424                                "offset": {"type": "integer", "example": 10, "nullable": True},
425                                "total": {"type": "integer", "example": 1642, "nullable": True}
426                            },
427                            "nullable": True
428                        }
429                    }
430                },
431                "_warnings": {
432                    "type": "array",
433                    "items": {
434                        "type": "object",
435                        "properties": {
436                            "code": {"type": "string"},
437                            "message": {"type": "string"},
438                            "exception": {"type": "string"}
439                        }
440                    }
441                },
442                "_errors": {
443                    "type": "array",
444                    "items": {
445                        "type": "object",
446                        "properties": {
447                            "code": {"type": "string"},
448                            "message": {"type": "string"},
449                            "exception": {"type": "string"}
450                        }
451                    }
452                }
453            }
454        }
455    return {
456        http_code: {
457            "content": {"application/json": {"schema": enveloped_schema if is_envelope else body_schema}},
458            "description": description
459        }
460    }

Generate the success responses for a given method (i.e. 201 for create-type actions and 200 for everything else).

Parameters
  • method: Name of the method (e.g. get_single, get_global, post_single, post_global, etc.)
  • endpoint: The endpoint instance
  • is_envelope: Whether the generated schema should be enveloped in a response object
Returns

A dictionary, e.g. {"200": {...}}

def generate_http_method_request_body(http_method, endpoint) -> dict:
463def generate_http_method_request_body(http_method, endpoint) -> dict:
464    """
465    Generate the request body for a given method (i.e. the body schema).
466    :param http_method: Name of the method (e.g. `get`, `post`, etc.)
467    :param endpoint: The endpoint instance
468    """
469    if http_method not in ["post", "put"]:
470        return {}
471    body_schema_path = f"../schemas/{endpoint._get_body_schema_name()}.yml#/{endpoint._get_body_schema_name()}"
472    body_schema = {"$ref": body_schema_path}
473    request_content_type = endpoint._get_request_content_type(http_method)
474    return {
475        "requestBody": {
476            "required": False,
477            "content": {request_content_type: {"schema": body_schema}}
478        }
479    }

Generate the request body for a given method (i.e. the body schema).

Parameters
  • http_method: Name of the method (e.g. get, post, etc.)
  • endpoint: The endpoint instance
def http_method_summary(method, endpoint):
482def http_method_summary(method, endpoint):
483    """
484    Generate the summary for a given method (i.e. "Get City list" or "Create Country")
485    :param method:
486    :param endpoint:
487    :return:
488    """
489    http_method = method.replace("_global", "").replace("_single", "")
490    is_global = "_global" in method and endpoint.__class__.global_is_array()
491    prefix_map = {
492        "get": "Get",
493        "post": "Create",
494        "put": "Update",
495        "delete": "Delete",
496        "options": "CORS for"
497    }
498    suffix_map = {
499        "get": " list" if is_global else "",
500        "post": "",
501        "put": "",
502        "delete": "",
503        "options": ""
504    }
505    return f"{prefix_map[http_method.lower()]} {endpoint.__class__.__name__}{suffix_map[http_method.lower()]}"

Generate the summary for a given method (i.e. "Get City list" or "Create Country")

Parameters
  • method:
  • endpoint:
Returns
def generate_endpoint_http_method(method: str, endpoint, enveloped_schema: bool = True) -> dict:
508def generate_endpoint_http_method(method: str, endpoint, enveloped_schema: bool = True) -> dict:
509    """
510    Generate the OpenAPI definition for a given endpoint method.
511    :param method: Name of the method (e.g. `get_single`, `get_global`, `post_single`, `post_global`, etc.)
512    :param endpoint: The endpoint instance
513    :param enveloped_schema: Whether to include the envelope header and change the returned schema to be in a response envelope
514    """
515    cls = endpoint.__class__
516    http_method = method.replace("_global", "").replace("_single", "")
517    lambda_arn_variable = f"{camelCase_to_snake_case(cls.__name__)}_lambda_arn"
518    return {
519        http_method: {
520            "summary": http_method_summary(method, endpoint),
521            "tags": [cls.__name__],
522            **generate_http_method_security(cls, method),
523            **generate_http_method_parameters(method, endpoint, enveloped_schema),
524            **generate_http_method_request_body(http_method, endpoint),
525            "responses": {
526                **generate_http_method_ok_response(method, endpoint, enveloped_schema),
527                "400": {"$ref": "./errors.yml#/400"},
528                "401": {"$ref": "./errors.yml#/401"},
529                "403": {"$ref": "./errors.yml#/403"},
530                "404": {"$ref": "./errors.yml#/404"},
531                "409": {"$ref": "./errors.yml#/409"}
532            },
533            **apigateway_integration_block(lambda_arn_variable, endpoint, http_method)
534        }
535    }

Generate the OpenAPI definition for a given endpoint method.

Parameters
  • method: Name of the method (e.g. get_single, get_global, post_single, post_global, etc.)
  • endpoint: The endpoint instance
  • enveloped_schema: Whether to include the envelope header and change the returned schema to be in a response envelope