tasks/tools/open_api_tool.py (301 lines of code) (raw):

# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance # with the License. A copy of the License is located at # # http://www.apache.org/licenses/LICENSE-2.0 # # or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES # OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions # and limitations under the License. from openapi_schema_pydantic import * from ideadatamodel import ( constants, IdeaOpenAPISpecEntry, SocaEnvelope ) from tasks import idea from ideasdk.utils import Utils from typing import Dict, Type, Optional, List from pydantic import BaseModel, AnyHttpUrl class OpenAPITool: """ Generate OpenAPI 3.0 Specification for IDEA APIs Refer: https://github.com/OAI/OpenAPI-Specification and https://swagger.io/docs/specification/about/ for more details on OpenAPI 3.0 Specification """ def __init__(self, entries: List[IdeaOpenAPISpecEntry], api_doc: Optional[Dict], enable_file_transfer_entries: bool = False, module_id: str = None, module_version: str = None, server_url: str = None): self.entries = entries # dynamic variables rendered by SocaServer at run time based on request. self.module_id = Utils.get_as_string(module_id, '{{ module_id }}') self.module_version = Utils.get_as_string(module_version, '{{ module_version }}') self.server_url = Utils.get_as_string(server_url, '{{ server_url }}') self.enable_file_transfer_entries = Utils.get_as_bool(enable_file_transfer_entries, False) # spec - top level - enforce mandatory items self.api_doc_spec = api_doc['spec'] self.title = self.api_doc_spec['title'] self.description = self.api_doc_spec['description'] # initialize spec tags self.spec_tags: List[Tag] = [] doc_tags = Utils.get_value_as_list('tags', self.api_doc_spec, []) for doc_tag in doc_tags: spec_tag = Tag( name=doc_tag['name'], description=doc_tag['description'] ) external_docs = Utils.get_value_as_dict('external_docs', doc_tag) if external_docs is not None: external_doc_url = external_docs['url'] external_doc_description = Utils.get_value_as_string('description', external_docs) spec_tag.externalDocs = ExternalDocumentation( url=AnyHttpUrl(url=external_doc_url, scheme=external_doc_url.split(':')[0]), description=external_doc_description ) self.spec_tags.append(spec_tag) # initialize doc entries as a map self.api_doc_entries: Dict[str, Dict] = {} entries = Utils.get_value_as_list('entries', self.api_doc_spec, []) for entry in entries: namespace = entry['namespace'] self.api_doc_entries[namespace] = entry # spec component schemas self.schemas: Dict[str, Schema] = {} # open api spec self.open_api_spec: Optional[OpenAPI] = None @staticmethod def get_mutable_json_schema(model: Type[BaseModel]) -> Dict: """ convert a Pydantic data model class to JSON Schema return a deep copy as model.schema() returns a cached copy """ return Utils.deep_copy(model.schema()) def build_schema(self, payload_type: Type[BaseModel], request: bool, listing: bool) -> Dict: """ convert a request or response payload to OpenAPI Spec request or response operation component * convert IDEA datamodel objects to JSON Schema * clean up JSON schema based on IDEA functionality and operation type * generate unique Envelope per request/response operation, so that generated clients can serialize/deserialize the applicable payloads for the right type. * collect all JSON schema definitions along the way :param Type[BaseModel] payload_type: IDEA API Request or Response Payload Type :param bool request: indicate if the operation is a request or response operation :param bool listing: indicate if the operation is a listing operation :return: """ envelope_schema = self.get_mutable_json_schema(SocaEnvelope) for name, value in envelope_schema['definitions'].items(): if name == 'SocaAuthScope': continue self.schemas[name] = Schema(**value) del envelope_schema['properties']['scope'] if request: del envelope_schema['properties']['error_code'] del envelope_schema['properties']['message'] del envelope_schema['properties']['success'] del envelope_schema['definitions'] api_schema = self.get_mutable_json_schema(payload_type) request_title = api_schema["title"] if 'definitions' in api_schema: for name, value in api_schema['definitions'].items(): self.schemas[name] = Schema(**value) del api_schema['definitions'] if request and listing: if 'listing' in api_schema['properties']: del api_schema['properties']['listing'] self.schemas[request_title] = Schema(**api_schema) envelope_schema['properties']['payload']['$ref'] = f'#/definitions/{request_title}' envelope_schema['title'] = f'{request_title}Envelope' content = Utils.to_yaml(envelope_schema) content = content.replace('#/definitions', '#/components/schemas') return Utils.from_yaml(content) @staticmethod def add_file_transfer_paths(spec_paths: Paths): """ file transfer routes for upload and download are served via special routes exposed in SocaServer. needs separate handling for these routes as the Http Methods are different from all other IDEA APIs. """ response_json = Schema( type='object', properties={ 'error_code': Schema( type='string' ), 'success': Schema( type='boolean' ), 'message': Schema( type='string' ) } ) # file upload spec_paths['/FileBrowser.UploadFile'] = PathItem( put=Operation( tags=['FileBrowser'], operationId='FileBrowser.UploadFile', security=[{ 'BearerToken': [] }], parameters=[ Parameter( name='cwd', description='Current Working Directory', required=True, param_in='query', param_schema=Schema(type='string') ) ], requestBody=RequestBody( content={ 'multipart/form-data': MediaType( media_type_schema=Schema( type='object', properties={ 'files[]': Schema( type='array', items=Schema( type='string', schema_format='binary' ) ) } ) ) } ), responses={ '200': Response( description='UploadFile Response', content={ 'application/json': MediaType( media_type_schema=response_json ) } ) } ) ) # file download spec_paths['/FileBrowser.DownloadFile'] = PathItem( get=Operation( tags=['FileBrowser'], operationId='FileBrowser.DownloadFile', security=[{ 'BearerToken': [] }], parameters=[ Parameter( name='file', description='Path of the file to download', required=True, param_in='query', param_schema=Schema(type='string') ) ], responses={ '200': Response( description='DownloadFile Response', content={ 'application/json': MediaType( media_type_schema=response_json ), 'text/plain': MediaType( media_type_schema=Schema( type='string' ) ), 'application/xml': MediaType( media_type_schema=Schema( type='string' ) ), 'image/*': MediaType( media_type_schema=Schema( type='string', schema_format='binary' ) ), '*/*': MediaType( media_type_schema=Schema( type='string', schema_format='binary' ) ) } ) } ) ) def get_examples(self, namespace: str, is_request: bool) -> Optional[Dict[str, Example]]: api_doc_entry = Utils.get_value_as_dict(namespace, self.api_doc_entries) if api_doc_entry is None: return None if is_request: operation_type = Utils.get_value_as_dict('request', api_doc_entry) else: operation_type = Utils.get_value_as_dict('response', api_doc_entry) if operation_type is None: return None examples = Utils.get_value_as_list('examples', operation_type) if examples is None or len(examples) == 0: return None result = {} for example_entry in examples: example_name = example_entry['name'] try: result[example_name] = Example( summary=Utils.get_value_as_string('description', example_entry), value=Utils.from_json(example_entry['value']) ) except Exception as e: operation_tag = 'request' if is_request else 'response' idea.console.warning(f'[{namespace}] ({operation_tag}) failed to parse json value for example: {example_name}, {e}') if len(result) == 0: return None return result def build_paths(self) -> Paths: """ convert spec entry to OpenAPISpec operations. :return: Paths """ spec_paths = {} for entry in self.entries: path = f'/{entry.namespace}' component_tag = entry.namespace.split('.')[0] operation_title_prefix = entry.namespace.split('.')[-1] request_schema = Schema(**self.build_schema( payload_type=entry.request, request=True, listing=entry.is_listing )) result_schema = Schema(**self.build_schema( payload_type=entry.result, request=False, listing=entry.is_listing )) spec_paths[path] = PathItem( post=Operation( operationId=entry.namespace, tags=[component_tag], security=[] if entry.is_public else [{ 'BearerToken': [] }], requestBody=RequestBody( description=f'{operation_title_prefix} Request', content={ 'application/json': MediaType( media_type_schema=request_schema, examples=self.get_examples(namespace=entry.namespace, is_request=True) ) }, required=True ), responses={ "200": Response( description=f'{operation_title_prefix} Response', content={ 'application/json': MediaType( media_type_schema=result_schema, examples=self.get_examples(namespace=entry.namespace, is_request=False) ) } ) } ) ) if self.enable_file_transfer_entries: self.add_file_transfer_paths(spec_paths) return spec_paths def build(self) -> OpenAPI: return OpenAPI( info=Info( title=self.title, description=f'{self.description} (ModuleId: {self.module_id})', version=self.module_version, ), servers=[ Server(url=self.server_url) ], tags=self.spec_tags, openapi=constants.OPEN_API_SPEC_VERSION, paths=self.build_paths(), components=Components( securitySchemes={ 'BearerToken': SecurityScheme( type='http', scheme='bearer' ) }, schemas=self.schemas ) ) def generate(self, output_format: str = 'yaml') -> str: if self.open_api_spec is None: idea.console.print('processing openapi spec entries and documentation ...') self.open_api_spec = self.build() if output_format == 'json': idea.console.print('converting to json ...') content = Utils.to_json(self.open_api_spec) else: idea.console.print('converting to yaml ...') content = Utils.to_yaml(self.open_api_spec) idea.console.print('post-processing ...') return content.replace('#/definitions', '#/components/schemas')