diff --git a/localstack/services/apigateway/models.py b/localstack/services/apigateway/models.py index b589321226f2d..6f24474967593 100644 --- a/localstack/services/apigateway/models.py +++ b/localstack/services/apigateway/models.py @@ -30,7 +30,7 @@ class RestApiContainer: documentation_parts: Dict[str, DocumentationPart] # not used yet, still in moto gateway_responses: Dict[str, GatewayResponse] - # not used yet, still in moto + # maps Model name -> Model models: Dict[str, Model] # maps ResourceId of a Resource to its children ResourceIds resource_children: Dict[str, List[str]] diff --git a/localstack/services/apigateway/provider.py b/localstack/services/apigateway/provider.py index d88a0e3ae28f4..a429db9145570 100644 --- a/localstack/services/apigateway/provider.py +++ b/localstack/services/apigateway/provider.py @@ -542,6 +542,19 @@ def update_method( remove_empty_attributes_from_method(response) return response + def delete_method( + self, context: RequestContext, rest_api_id: String, resource_id: String, http_method: String + ) -> None: + moto_backend = apigw_models.apigateway_backends[context.account_id][context.region] + moto_rest_api: MotoRestAPI = moto_backend.apis.get(rest_api_id) + if not moto_rest_api or not (moto_resource := moto_rest_api.resources.get(resource_id)): + raise NotFoundException("Invalid Resource identifier specified") + + if not (moto_resource.resource_methods.get(http_method)): + raise NotFoundException("Invalid Method identifier specified") + + call_moto(context) + # method responses @handler("UpdateMethodResponse", expand=False) @@ -1323,11 +1336,18 @@ def delete_model( self, context: RequestContext, rest_api_id: String, model_name: String ) -> None: store = get_apigateway_store(account_id=context.account_id, region=context.region) - if rest_api_id not in store.rest_apis or not ( - store.rest_apis[rest_api_id].models.pop(model_name, None) + + if ( + rest_api_id not in store.rest_apis + or model_name not in store.rest_apis[rest_api_id].models ): raise NotFoundException(f"Invalid model name specified: {model_name}") + moto_rest_api = get_moto_rest_api(context, rest_api_id) + validate_model_in_use(moto_rest_api, model_name) + + store.rest_apis[rest_api_id].models.pop(model_name, None) + # --------------- # UTIL FUNCTIONS @@ -1392,6 +1412,16 @@ def is_variable_path(path_part: str) -> bool: return path_part.startswith("{") and path_part.endswith("}") +def validate_model_in_use(moto_rest_api: MotoRestAPI, model_name: str) -> None: + for resource in moto_rest_api.resources.values(): + for method in resource.resource_methods.values(): + if model_name in set(method.request_models.values()): + path = f"{resource.get_path()}/{method.http_method}" + raise ConflictException( + f"Cannot delete model '{model_name}', is referenced in method request: {path}" + ) + + def create_custom_context( context: RequestContext, action: str, parameters: ServiceRequest ) -> RequestContext: diff --git a/tests/integration/apigateway/test_apigateway_api.py b/tests/integration/apigateway/test_apigateway_api.py index 4e9e2e0916f21..f186fdf83f4ef 100644 --- a/tests/integration/apigateway/test_apigateway_api.py +++ b/tests/integration/apigateway/test_apigateway_api.py @@ -788,6 +788,14 @@ def test_method_lifecycle( ) snapshot.match("del-base-method-response", del_base_method_response) + with pytest.raises(ClientError) as e: + apigateway_client.get_method(restApiId=api_id, resourceId=root_id, httpMethod="ANY") + snapshot.match("get-deleted-method-response", e.value.response) + + with pytest.raises(ClientError) as e: + apigateway_client.delete_method(restApiId=api_id, resourceId=root_id, httpMethod="ANY") + snapshot.match("delete-deleted-method-response", e.value.response) + @pytest.mark.aws_validated def test_method_request_parameters( self, @@ -835,6 +843,126 @@ def test_method_request_parameters( snapshot.match("req-params-same-name", e.value.response) + @pytest.mark.aws_validated + @pytest.mark.skip_snapshot_verify( + paths=[ + "$.delete-model-used-by-2-method.Error.Message", + "$.delete-model-used-by-2-method.message", # we can't guarantee the last method will be the same as AWS + ] + ) + def test_put_method_model( + self, + apigateway_client, + apigw_create_rest_api, + snapshot, + ): + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", description="testing resource method model" + ) + api_id = response["id"] + root_rest_api_resource = apigateway_client.get_resources(restApiId=api_id) + root_id = root_rest_api_resource["items"][0]["id"] + + create_model = apigateway_client.create_model( + name="MySchema", + restApiId=api_id, + contentType="application/json", + description="", + schema=json.dumps({"title": "MySchema", "type": "object"}), + ) + snapshot.match("create-model", create_model) + + create_model_2 = apigateway_client.create_model( + name="MySchemaTwo", + restApiId=api_id, + contentType="application/json", + description="", + schema=json.dumps({"title": "MySchemaTwo", "type": "object"}), + ) + snapshot.match("create-model-2", create_model_2) + + put_method_response = apigateway_client.put_method( + restApiId=api_id, + resourceId=root_id, + httpMethod="ANY", + authorizationType="NONE", + requestModels={"application/json": "MySchema"}, + ) + snapshot.match("put-method-request-models", put_method_response) + + with pytest.raises(ClientError) as e: + apigateway_client.delete_model(restApiId=api_id, modelName="MySchema") + snapshot.match("delete-model-used", e.value.response) + + patch_operations = [ + {"op": "replace", "path": "/requestModels/application~1json", "value": "MySchemaTwo"}, + ] + + update_method_model = apigateway_client.update_method( + restApiId=api_id, + resourceId=root_id, + httpMethod="ANY", + patchOperations=patch_operations, + ) + snapshot.match("update-method-model", update_method_model) + + delete_model = apigateway_client.delete_model(restApiId=api_id, modelName="MySchema") + snapshot.match("delete-model-unused", delete_model) + + with pytest.raises(ClientError) as e: + apigateway_client.delete_model(restApiId=api_id, modelName="MySchemaTwo") + snapshot.match("delete-model-used-2", e.value.response) + + # create a subresource using MySchemaTwo + resource = apigateway_client.create_resource( + restApiId=api_id, parentId=root_id, pathPart="test" + ) + put_method_response = apigateway_client.put_method( + restApiId=api_id, + resourceId=resource["id"], + httpMethod="ANY", + authorizationType="NONE", + requestModels={"application/json": "MySchemaTwo"}, + ) + snapshot.match("put-method-2-request-models", put_method_response) + + # assert that the error raised gives the path of the subresource + with pytest.raises(ClientError) as e: + apigateway_client.delete_model(restApiId=api_id, modelName="MySchemaTwo") + snapshot.match("delete-model-used-by-2-method", e.value.response) + + patch_operations = [ + {"op": "remove", "path": "/requestModels/application~1json", "value": "MySchemaTwo"}, + ] + + # remove the Model from the subresource + update_method_model = apigateway_client.update_method( + restApiId=api_id, + resourceId=resource["id"], + httpMethod="ANY", + patchOperations=patch_operations, + ) + snapshot.match("update-method-model-2", update_method_model) + + if is_aws_cloud(): + # just to be sure the change is properly set in AWS + time.sleep(3) + + # assert that the error raised gives the path of the resource now + with pytest.raises(ClientError) as e: + apigateway_client.delete_model(restApiId=api_id, modelName="MySchemaTwo") + snapshot.match("delete-model-used-by-method-1", e.value.response) + + # delete the Method using MySchemaTwo + delete_method = apigateway_client.delete_method( + restApiId=api_id, resourceId=root_id, httpMethod="ANY" + ) + snapshot.match("delete-method-using-model-2", delete_method) + + # assert we can now delete MySchemaTwo + delete_model = apigateway_client.delete_model(restApiId=api_id, modelName="MySchemaTwo") + snapshot.match("delete-model-unused-2", delete_model) + @pytest.mark.aws_validated def test_put_method_validation( self, diff --git a/tests/integration/apigateway/test_apigateway_api.snapshot.json b/tests/integration/apigateway/test_apigateway_api.snapshot.json index aa5076c6d1d20..766ea6a98bb32 100644 --- a/tests/integration/apigateway/test_apigateway_api.snapshot.json +++ b/tests/integration/apigateway/test_apigateway_api.snapshot.json @@ -1175,7 +1175,7 @@ } }, "tests/integration/apigateway/test_apigateway_api.py::TestApiGatewayApi::test_method_lifecycle": { - "recorded-date": "24-02-2023, 19:17:18", + "recorded-date": "15-03-2023, 12:06:26", "recorded-content": { "put-base-method-response": { "apiKeyRequired": false, @@ -1200,6 +1200,28 @@ "HTTPHeaders": {}, "HTTPStatusCode": 204 } + }, + "get-deleted-method-response": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Method identifier specified" + }, + "message": "Invalid Method identifier specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "delete-deleted-method-response": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Method identifier specified" + }, + "message": "Invalid Method identifier specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } } } }, @@ -1855,5 +1877,143 @@ } } } + }, + "tests/integration/apigateway/test_apigateway_api.py::TestApiGatewayApi::test_put_method_model": { + "recorded-date": "15-03-2023, 12:12:59", + "recorded-content": { + "create-model": { + "contentType": "application/json", + "id": "", + "name": "", + "schema": { + "title": "", + "type": "object" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "create-model-2": { + "contentType": "application/json", + "id": "", + "name": "Two", + "schema": { + "title": "Two", + "type": "object" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "put-method-request-models": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "ANY", + "requestModels": { + "application/json": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "delete-model-used": { + "Error": { + "Code": "ConflictException", + "Message": "Cannot delete model '', is referenced in method request: //ANY" + }, + "message": "Cannot delete model '', is referenced in method request: //ANY", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 409 + } + }, + "update-method-model": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "ANY", + "requestModels": { + "application/json": "Two" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-model-unused": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "delete-model-used-2": { + "Error": { + "Code": "ConflictException", + "Message": "Cannot delete model 'Two', is referenced in method request: //ANY" + }, + "message": "Cannot delete model 'Two', is referenced in method request: //ANY", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 409 + } + }, + "put-method-2-request-models": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "ANY", + "requestModels": { + "application/json": "Two" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "delete-model-used-by-2-method": { + "Error": { + "Code": "ConflictException", + "Message": "Cannot delete model 'Two', is referenced in method request: /test/ANY" + }, + "message": "Cannot delete model 'Two', is referenced in method request: /test/ANY", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 409 + } + }, + "update-method-model-2": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "ANY", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-model-used-by-method-1": { + "Error": { + "Code": "ConflictException", + "Message": "Cannot delete model 'Two', is referenced in method request: //ANY" + }, + "message": "Cannot delete model 'Two', is referenced in method request: //ANY", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 409 + } + }, + "delete-method-using-model-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "delete-model-unused-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + } + } } }