diff --git a/samcli/lib/package/packageable_resources.py b/samcli/lib/package/packageable_resources.py index e9809b7fb0..a52ff32fed 100644 --- a/samcli/lib/package/packageable_resources.py +++ b/samcli/lib/package/packageable_resources.py @@ -4,7 +4,7 @@ import logging import os import shutil -from typing import Dict, Optional, Union +from typing import Any, Dict, List, Optional, Tuple, Union, cast import jmespath from botocore.utils import set_value_from_jmespath @@ -40,6 +40,7 @@ AWS_LAMBDA_LAYERVERSION, AWS_SERVERLESS_API, AWS_SERVERLESS_FUNCTION, + AWS_SERVERLESS_GRAPHQLAPI, AWS_SERVERLESS_HTTPAPI, AWS_SERVERLESS_LAYERVERSION, AWS_SERVERLESS_STATEMACHINE, @@ -89,7 +90,7 @@ class ResourceZip(Resource): Base class representing a CloudFormation resource that can be exported """ - RESOURCE_TYPE: Optional[str] = None + RESOURCE_TYPE: str = "" PROPERTY_NAME: str = "" PACKAGE_NULL_PROPERTY = True # Set this property to True in base class if you want the exporter to zip @@ -133,13 +134,23 @@ def export(self, resource_id: str, resource_dict: Optional[Dict], parent_dir: st if temp_dir: shutil.rmtree(temp_dir) - def do_export(self, resource_id, resource_dict, parent_dir): + def do_export( + self, + resource_id, + resource_dict, + parent_dir, + property_path: Optional[str] = None, + local_path: Optional[str] = None, + ): """ Default export action is to upload artifacts and set the property to S3 URL of the uploaded object If code signing configuration is provided for function/layer, uploaded artifact will be replaced by signed artifact location """ + if property_path is None: + property_path = self.PROPERTY_NAME + uploader = cast(S3Uploader, self.uploader) # code signer only accepts files which has '.zip' extension in it # so package artifact with '.zip' if it is required to be signed should_sign_package = self.code_signer.should_sign_package(resource_id) @@ -148,16 +159,17 @@ def do_export(self, resource_id, resource_dict, parent_dir): self.RESOURCE_TYPE, resource_id, resource_dict, - self.PROPERTY_NAME, + property_path, parent_dir, - self.uploader, + uploader, artifact_extension, + local_path, ) if should_sign_package: uploaded_url = self.code_signer.sign_package( - resource_id, uploaded_url, self.uploader.get_version_of_artifact(uploaded_url) + resource_id, uploaded_url, uploader.get_version_of_artifact(uploaded_url) ) - set_value_from_jmespath(resource_dict, self.PROPERTY_NAME, uploaded_url) + set_value_from_jmespath(resource_dict, property_path, uploaded_url) def delete(self, resource_id, resource_dict): """ @@ -585,6 +597,116 @@ def get_property_value(self, resource_dict): return jmespath.search(self.PROPERTY_NAME, resource_dict) +class GraphQLApiSchemaResource(ResourceZip): + RESOURCE_TYPE = AWS_SERVERLESS_GRAPHQLAPI + PROPERTY_NAME = RESOURCES_WITH_LOCAL_PATHS[RESOURCE_TYPE][0] + # Don't package the directory if SchemaUri is omitted. + # Necessary to support SchemaInline + PACKAGE_NULL_PROPERTY = False + + +class GraphQLApiCodeResource(ResourceZip): + """CodeUri for GraphQLApi resource. + + There can be more than a single instance of CodeUri property in GraphQLApi Resolvers and Functions. + This class handles them all. + + GraphQLApi dict shape looks like the following (yaml representation) + >>> Resolvers: + Mutation: + Resolver1: + CodeUri: ... + Pipeline: + - Func1 + - Func2 + Query: + Resolver2: + CodeUri: ... + Pipeline: + - Func3 + Functions: + Func1: + CodeUri: ... + Func2: + CodeUri: ... + Func3: + CodeUri: ... + ... # other properties, which are not important here + """ + + RESOURCE_TYPE = AWS_SERVERLESS_GRAPHQLAPI + PROPERTY_NAME = RESOURCES_WITH_LOCAL_PATHS[RESOURCE_TYPE][1] + # if CodeUri is omitted the directory is not packaged because it's necessary to support CodeInline + PACKAGE_NULL_PROPERTY = False + + def export(self, resource_id: str, resource_dict: Optional[Dict], parent_dir: str): + if resource_dict is None: + return + + if resource_not_packageable(resource_dict): + return + + # to be able to set different nested properties to S3 uri, paths are necessary + # jmespath doesn't provide that functionality, thus custom implementation + paths_values = self._find_all_with_property_name(resource_dict) + for property_path, property_value in paths_values: + if isinstance(property_value, dict): + LOG.debug("Property %s of %s resource is not a URL", self.PROPERTY_NAME, resource_id) + return + + # If property is a file but not a zip file, place file in temp + # folder and send the temp folder to be zipped + temp_dir = None + if is_local_file(property_value) and not is_zip_file(property_value) and self.FORCE_ZIP: + temp_dir = copy_to_temp_dir(property_value) + set_value_from_jmespath(resource_dict, property_path, temp_dir) + + try: + self.do_export( + resource_id, resource_dict, parent_dir, property_path=property_path, local_path=property_value + ) + + except Exception as ex: + LOG.debug("Unable to export", exc_info=ex) + raise exceptions.ExportFailedError( + resource_id=resource_id, property_name=property_path, property_value=property_value, ex=ex + ) + finally: + if temp_dir: + shutil.rmtree(temp_dir) + + def _find_all_with_property_name(self, graphql_dict: Dict[str, Any]) -> List[Tuple[str, Union[str, Dict]]]: + """Find paths to the all properties with self.PROPERTY_NAME name and their (properties) values. + + It leverages the knowledge of GraphQLApi structure instead of doing generic search in the graph. + + Parameters + ---------- + graphql_dict + GraphQLApi resource dict + + Returns + ------- + list of tuple (path, value) for all found properties which has property_name + """ + # need to look up only in "Resolvers" and "Functions" subtrees + resolvers_and_functions = {k: graphql_dict[k] for k in ("Resolvers", "Functions") if k in graphql_dict} + stack: List[Tuple[Dict[str, Any], str]] = [(resolvers_and_functions, "")] + paths_values: List[Tuple[str, Union[str, Dict]]] = [] + + while stack: + node, path = stack.pop() + if isinstance(node, dict): + for key, value in node.items(): + if key == self.PROPERTY_NAME: + paths_values.append((f"{path}{key}", value)) + elif isinstance(value, dict): + stack.append((value, f"{path}{key}.")) + # there is no need to handle lists because + # paths to "CodeUri" within "Resolvers" and "Functions" doesn't have lists + return paths_values + + RESOURCES_EXPORT_LIST = [ ServerlessFunctionResource, ServerlessFunctionImageResource, @@ -610,6 +732,8 @@ def get_property_value(self, resource_dict): CloudFormationModuleVersionModulePackage, CloudFormationResourceVersionSchemaHandlerPackage, ECRResource, + GraphQLApiSchemaResource, + GraphQLApiCodeResource, ] METADATA_EXPORT_LIST = [ServerlessRepoApplicationReadme, ServerlessRepoApplicationLicense] diff --git a/samcli/lib/package/utils.py b/samcli/lib/package/utils.py index 6434a70d5f..8650d3efa8 100644 --- a/samcli/lib/package/utils.py +++ b/samcli/lib/package/utils.py @@ -57,7 +57,7 @@ def is_path_value_valid(path): return isinstance(path, str) -def make_abs_path(directory, path): +def make_abs_path(directory: str, path: str) -> str: if is_path_value_valid(path) and not os.path.isabs(path): return os.path.normpath(os.path.join(directory, path)) return path @@ -130,10 +130,11 @@ def upload_local_artifacts( resource_type: str, resource_id: str, resource_dict: Dict, - property_name: str, + property_path: str, parent_dir: str, uploader: S3Uploader, extension: Optional[str] = None, + local_path: Optional[str] = None, ) -> str: """ Upload local artifacts referenced by the property at given resource and @@ -150,28 +151,28 @@ def upload_local_artifacts( :param resource_type: Type of the CloudFormation resource :param resource_id: Id of the CloudFormation resource :param resource_dict: Dictionary containing resource definition - :param property_name: Property name of CloudFormation resource where this + :param property_path: Json path to the property of SAM or CloudFormation resource where the local path is present :param parent_dir: Resolve all relative paths with respect to this directory :param uploader: Method to upload files to S3 :param extension: Extension of the uploaded artifact + :param local_path: Local path for the cases when search return more than single result :return: S3 URL of the uploaded object :raise: ValueError if path is not a S3 URL or a local path """ - local_path = jmespath.search(property_name, resource_dict) - if local_path is None: - # Build the root directory and upload to S3 - local_path = parent_dir + # if local_path is not passed and search returns nothing + # build the root directory and upload to S3 + local_path = jmespath.search(property_path, resource_dict) or parent_dir if is_s3_protocol_url(local_path): # A valid CloudFormation template will specify artifacts as S3 URLs. # This check is supporting the case where your resource does not # refer to local artifacts # Nothing to do if property value is an S3 URL - LOG.debug("Property %s of %s is already a S3 URL", property_name, resource_id) + LOG.debug("Property %s of %s is already a S3 URL", property_path, resource_id) return cast(str, local_path) local_path = make_abs_path(parent_dir, local_path) @@ -189,7 +190,7 @@ def upload_local_artifacts( if is_local_file(local_path): return uploader.upload_with_dedup(local_path) - raise InvalidLocalPathError(resource_id=resource_id, property_name=property_name, local_path=local_path) + raise InvalidLocalPathError(resource_id=resource_id, property_name=property_path, local_path=local_path) def resource_not_packageable(resource_dict): diff --git a/samcli/lib/utils/resources.py b/samcli/lib/utils/resources.py index 875f3bd997..5011f581dc 100644 --- a/samcli/lib/utils/resources.py +++ b/samcli/lib/utils/resources.py @@ -41,6 +41,7 @@ AWS_SERVERLESS_APPLICATION = "AWS::Serverless::Application" AWS_SERVERLESSREPO_APPLICATION = "AWS::ServerlessRepo::Application" +AWS_SERVERLESS_GRAPHQLAPI = "AWS::Serverless::GraphQLApi" AWS_APPSYNC_GRAPHQLSCHEMA = "AWS::AppSync::GraphQLSchema" AWS_APPSYNC_RESOLVER = "AWS::AppSync::Resolver" AWS_APPSYNC_FUNCTIONCONFIGURATION = "AWS::AppSync::FunctionConfiguration" @@ -61,12 +62,17 @@ METADATA_WITH_LOCAL_PATHS = {AWS_SERVERLESSREPO_APPLICATION: ["LicenseUrl", "ReadmeUrl"]} RESOURCES_WITH_LOCAL_PATHS = { + AWS_SERVERLESS_GRAPHQLAPI: ["SchemaUri", "CodeUri"], AWS_SERVERLESS_FUNCTION: ["CodeUri"], AWS_SERVERLESS_API: ["DefinitionUri"], AWS_SERVERLESS_HTTPAPI: ["DefinitionUri"], AWS_SERVERLESS_STATEMACHINE: ["DefinitionUri"], AWS_APPSYNC_GRAPHQLSCHEMA: ["DefinitionS3Location"], - AWS_APPSYNC_RESOLVER: ["RequestMappingTemplateS3Location", "ResponseMappingTemplateS3Location", "CodeS3Location"], + AWS_APPSYNC_RESOLVER: [ + "RequestMappingTemplateS3Location", + "ResponseMappingTemplateS3Location", + "CodeS3Location", + ], AWS_APPSYNC_FUNCTIONCONFIGURATION: [ "RequestMappingTemplateS3Location", "ResponseMappingTemplateS3Location", @@ -133,7 +139,11 @@ def get_packageable_resource_paths(): Resource Dictionary containing packageable resource types and their locations as a list. """ _resource_property_dict = defaultdict(list) - for _dict in (METADATA_WITH_LOCAL_PATHS, RESOURCES_WITH_LOCAL_PATHS, RESOURCES_WITH_IMAGE_COMPONENT): + for _dict in ( + METADATA_WITH_LOCAL_PATHS, + RESOURCES_WITH_LOCAL_PATHS, + RESOURCES_WITH_IMAGE_COMPONENT, + ): for key, value in _dict.items(): # Only add values to the list if they are different, same property name could be used with the resource # to package to different locations. diff --git a/tests/unit/lib/package/test_artifact_exporter.py b/tests/unit/lib/package/test_artifact_exporter.py index e622bd5904..1a2e7f2227 100644 --- a/tests/unit/lib/package/test_artifact_exporter.py +++ b/tests/unit/lib/package/test_artifact_exporter.py @@ -10,7 +10,7 @@ from contextlib import contextmanager, closing from unittest import mock -from unittest.mock import patch, Mock, MagicMock +from unittest.mock import call, patch, Mock, MagicMock from samcli.commands.package.exceptions import ExportFailedError from samcli.lib.package.permissions import ( @@ -35,6 +35,8 @@ ServerlessApplicationResource, ) from samcli.lib.package.packageable_resources import ( + GraphQLApiCodeResource, + GraphQLApiSchemaResource, is_s3_protocol_url, is_local_file, upload_local_artifacts, @@ -84,6 +86,20 @@ def get_mock(destination: Destination): self.code_signer_mock = Mock() self.code_signer_mock.should_sign_package.return_value = False + self.graphql_api_local_paths = ["resolvers/createFoo.js", "functions/func1.js", "functions/func2.js"] + self.graphql_api_resource_dict = { + "Resolvers": {"Mutation": {"createFoo": {"CodeUri": self.graphql_api_local_paths[0]}}}, + "Functions": { + "func1": {"CodeUri": self.graphql_api_local_paths[1]}, + "func2": {"CodeUri": self.graphql_api_local_paths[2]}, + }, + } + self.graphql_api_paths_to_property = [ + "Resolvers.Mutation.createFoo.CodeUri", + "Functions.func1.CodeUri", + "Functions.func2.CodeUri", + ] + def test_all_resources_export(self): uploaded_s3_url = "s3://foo/bar?versionId=baz" @@ -114,6 +130,8 @@ def test_all_resources_export(self): {"class": GlueJobCommandScriptLocationResource, "expected_result": {"ScriptLocation": uploaded_s3_url}}, {"class": CloudFormationModuleVersionModulePackage, "expected_result": uploaded_s3_url}, {"class": CloudFormationResourceVersionSchemaHandlerPackage, "expected_result": uploaded_s3_url}, + {"class": GraphQLApiSchemaResource, "expected_result": uploaded_s3_url}, + {"class": GraphQLApiCodeResource, "expected_result": [uploaded_s3_url, uploaded_s3_url, uploaded_s3_url]}, ] with patch("samcli.lib.package.packageable_resources.upload_local_artifacts") as upload_local_artifacts_mock: @@ -149,7 +167,9 @@ def _helper_verify_export_resources( resource_id = "id" - if "." in test_class.PROPERTY_NAME: + if test_class == GraphQLApiCodeResource: + resource_dict = self.graphql_api_resource_dict + elif "." in test_class.PROPERTY_NAME: reversed_property_names = test_class.PROPERTY_NAME.split(".") reversed_property_names.reverse() property_dict = {reversed_property_names[0]: "foo"} @@ -166,7 +186,43 @@ def _helper_verify_export_resources( resource_obj.export(resource_id, resource_dict, parent_dir) - if test_class in ( + if test_class == GraphQLApiCodeResource: + upload_local_artifacts_mock.assert_has_calls( + [ + call( + test_class.RESOURCE_TYPE, + resource_id, + resource_dict, + self.graphql_api_paths_to_property[0], + parent_dir, + s3_uploader_mock, + None, + self.graphql_api_local_paths[0], + ), + call( + test_class.RESOURCE_TYPE, + resource_id, + resource_dict, + self.graphql_api_paths_to_property[1], + parent_dir, + s3_uploader_mock, + None, + self.graphql_api_local_paths[1], + ), + call( + test_class.RESOURCE_TYPE, + resource_id, + resource_dict, + self.graphql_api_paths_to_property[2], + parent_dir, + s3_uploader_mock, + None, + self.graphql_api_local_paths[2], + ), + ], + any_order=True, + ) + elif test_class in ( ApiGatewayRestApiResource, LambdaFunctionResource, ElasticBeanstalkApplicationVersion, @@ -189,9 +245,16 @@ def _helper_verify_export_resources( parent_dir, s3_uploader_mock, None, + None, ) code_signer_mock.sign_package.assert_not_called() - if "." in test_class.PROPERTY_NAME: + if test_class == GraphQLApiCodeResource: + result = [ + self.graphql_api_resource_dict["Resolvers"]["Mutation"]["createFoo"][test_class.PROPERTY_NAME], + self.graphql_api_resource_dict["Functions"]["func1"][test_class.PROPERTY_NAME], + self.graphql_api_resource_dict["Functions"]["func2"][test_class.PROPERTY_NAME], + ] + elif "." in test_class.PROPERTY_NAME: top_level_property_name = test_class.PROPERTY_NAME.split(".")[0] result = resource_dict[top_level_property_name] else: @@ -529,6 +592,7 @@ class MockResource(ResourceZip): parent_dir, self.s3_uploader_mock, None, + None, ) self.assertEqual(resource_dict[resource.PROPERTY_NAME], s3_url) @@ -809,6 +873,7 @@ class MockResource(ResourceZip): parent_dir, self.s3_uploader_mock, None, + None, ) self.code_signer_mock.should_sign_package.assert_called_once_with(resource_id) self.code_signer_mock.sign_package.assert_not_called()