diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md index 5143fb9df..837d70efa 100644 --- a/IMPLEMENTATION_COVERAGE.md +++ b/IMPLEMENTATION_COVERAGE.md @@ -22,7 +22,7 @@ ## apigateway
-62% implemented +65% implemented - [X] create_api_key - [X] create_authorizer @@ -93,7 +93,7 @@ - [X] get_request_validator - [X] get_request_validators - [X] get_resource -- [ ] get_resources +- [X] get_resources - [X] get_rest_api - [ ] get_rest_apis - [ ] get_sdk @@ -111,13 +111,13 @@ - [X] get_vpc_links - [ ] import_api_keys - [ ] import_documentation_parts -- [ ] import_rest_api +- [X] import_rest_api - [X] put_gateway_response - [X] put_integration - [X] put_integration_response - [X] put_method - [X] put_method_response -- [ ] put_rest_api +- [X] put_rest_api - [ ] tag_resource - [ ] test_invoke_authorizer - [ ] test_invoke_method diff --git a/docs/docs/services/apigateway.rst b/docs/docs/services/apigateway.rst index 5369da258..a78ecf0db 100644 --- a/docs/docs/services/apigateway.rst +++ b/docs/docs/services/apigateway.rst @@ -100,7 +100,7 @@ apigateway - [X] get_request_validator - [X] get_request_validators - [X] get_resource -- [ ] get_resources +- [X] get_resources - [X] get_rest_api - [ ] get_rest_apis - [ ] get_sdk @@ -122,13 +122,21 @@ apigateway - [ ] import_api_keys - [ ] import_documentation_parts -- [ ] import_rest_api +- [X] import_rest_api + + Only a subset of the OpenAPI spec 3.x is currently implemented. + + - [X] put_gateway_response - [X] put_integration - [X] put_integration_response - [X] put_method - [X] put_method_response -- [ ] put_rest_api +- [X] put_rest_api + + Only a subset of the OpenAPI spec 3.x is currently implemented. + + - [ ] tag_resource - [ ] test_invoke_authorizer - [ ] test_invoke_method diff --git a/moto/apigateway/exceptions.py b/moto/apigateway/exceptions.py index 735492242..d8373bb8d 100644 --- a/moto/apigateway/exceptions.py +++ b/moto/apigateway/exceptions.py @@ -50,6 +50,25 @@ class IntegrationMethodNotDefined(BadRequestException): super().__init__("Enumeration value for HttpMethod must be non-empty") +class InvalidOpenAPIDocumentException(BadRequestException): + def __init__(self, cause): + super().__init__( + f"Failed to parse the uploaded OpenAPI document due to: {cause.message}" + ) + + +class InvalidOpenApiDocVersionException(BadRequestException): + def __init__(self): + super().__init__("Only OpenAPI 3.x.x are currently supported") + + +class InvalidOpenApiModeException(BadRequestException): + def __init__(self): + super().__init__( + 'Enumeration value of OpenAPI import mode must be "overwrite" or "merge"', + ) + + class InvalidResourcePathException(BadRequestException): def __init__(self): super().__init__( @@ -233,6 +252,13 @@ class BasePathNotFoundException(NotFoundException): super().__init__("Invalid base path mapping identifier specified") +class ResourceIdNotFoundException(NotFoundException): + code = 404 + + def __init__(self): + super().__init__("Invalid resource identifier specified") + + class VpcLinkNotFound(NotFoundException): code = 404 diff --git a/moto/apigateway/models.py b/moto/apigateway/models.py index 5d4340d26..ceea0e30f 100644 --- a/moto/apigateway/models.py +++ b/moto/apigateway/models.py @@ -6,10 +6,13 @@ import re from collections import defaultdict from copy import copy +from openapi_spec_validator import validate_spec import time from urllib.parse import urlparse import responses + +from openapi_spec_validator.exceptions import OpenAPIValidationError from moto.core import get_account_id, BaseBackend, BaseModel, CloudFormationModel from .utils import create_id, to_path from moto.core.utils import path_url, BackendDict @@ -27,9 +30,13 @@ from .exceptions import ( InvalidArn, InvalidIntegrationArn, InvalidHttpEndpoint, + InvalidOpenAPIDocumentException, + InvalidOpenApiDocVersionException, + InvalidOpenApiModeException, InvalidResourcePathException, AuthorizerNotFoundException, StageNotFoundException, + ResourceIdNotFoundException, RoleNotSpecified, NoIntegrationDefined, NoIntegrationResponseDefined, @@ -184,7 +191,7 @@ class Method(CloudFormationModel, dict): requestValidatorId=kwargs.get("request_validator_id"), ) ) - self.method_responses = {} + self["methodResponses"] = {} @staticmethod def cloudformation_name_type(): @@ -229,14 +236,14 @@ class Method(CloudFormationModel, dict): method_response = MethodResponse( response_code, response_models, response_parameters ) - self.method_responses[response_code] = method_response + self["methodResponses"][response_code] = method_response return method_response def get_response(self, response_code): - return self.method_responses.get(response_code) + return self["methodResponses"].get(response_code) def delete_response(self, response_code): - return self.method_responses.pop(response_code, None) + return self["methodResponses"].pop(response_code, None) class Resource(CloudFormationModel): @@ -288,7 +295,7 @@ class Resource(CloudFormationModel): backend = apigateway_backends[region_name] if parent == api_id: # A Root path (/) is automatically created. Any new paths should use this as their parent - resources = backend.list_resources(function_id=api_id) + resources = backend.get_resources(function_id=api_id) root_id = [resource for resource in resources if resource.path_part == "/"][ 0 ].id @@ -789,7 +796,7 @@ class RestAPI(CloudFormationModel): self.resources = {} self.models = {} self.request_validators = {} - self.add_child("/") # Add default child + self.default = self.add_child("/") # Add default child def __repr__(self): return str(self.id) @@ -811,8 +818,6 @@ class RestAPI(CloudFormationModel): } def apply_patch_operations(self, patch_operations): - def to_path(prop): - return "/" + prop for op in patch_operations: path = op[self.OPERATION_PATH] @@ -1270,12 +1275,80 @@ class APIGatewayBackend(BaseBackend): self.apis[api_id] = rest_api return rest_api + def import_rest_api(self, api_doc, fail_on_warnings): + """ + Only a subset of the OpenAPI spec 3.x is currently implemented. + """ + if fail_on_warnings: + try: + validate_spec(api_doc) + except OpenAPIValidationError as e: + raise InvalidOpenAPIDocumentException(e) + name = api_doc["info"]["title"] + description = api_doc["info"]["description"] + api = self.create_rest_api(name=name, description=description) + self.put_rest_api(api.id, api_doc, fail_on_warnings=fail_on_warnings) + return api + def get_rest_api(self, function_id): rest_api = self.apis.get(function_id) if rest_api is None: raise RestAPINotFound() return rest_api + def put_rest_api(self, function_id, api_doc, mode="merge", fail_on_warnings=False): + """ + Only a subset of the OpenAPI spec 3.x is currently implemented. + """ + if mode not in ["merge", "overwrite"]: + raise InvalidOpenApiModeException() + + if api_doc.get("swagger") is not None or ( + api_doc.get("openapi") is not None and api_doc["openapi"][0] != "3" + ): + raise InvalidOpenApiDocVersionException() + + if fail_on_warnings: + try: + validate_spec(api_doc) + except OpenAPIValidationError as e: + raise InvalidOpenAPIDocumentException(e) + + if mode == "overwrite": + api = self.get_rest_api(function_id) + api.resources = {} + api.default = api.add_child("/") # Add default child + + for (path, resource_doc) in sorted( + api_doc["paths"].items(), key=lambda x: x[0] + ): + parent_path_part = path[0 : path.rfind("/")] or "/" + parent_resource_id = ( + self.apis[function_id].get_resource_for_path(parent_path_part).id + ) + resource = self.create_resource( + function_id=function_id, + parent_resource_id=parent_resource_id, + path_part=path[path.rfind("/") + 1 :], + ) + + for (method_type, method_doc) in resource_doc.items(): + method_type = method_type.upper() + if method_doc.get("x-amazon-apigateway-integration") is None: + self.put_method(function_id, resource.id, method_type, None) + method_responses = method_doc.get("responses", {}).items() + for (response_code, _) in method_responses: + self.put_method_response( + function_id, + resource.id, + method_type, + response_code, + response_models=None, + response_parameters=None, + ) + + return self.get_rest_api(function_id) + def update_rest_api(self, function_id, patch_operations): rest_api = self.apis.get(function_id) if rest_api is None: @@ -1290,19 +1363,24 @@ class APIGatewayBackend(BaseBackend): rest_api = self.apis.pop(function_id) return rest_api - def list_resources(self, function_id): + def get_resources(self, function_id): api = self.get_rest_api(function_id) return api.resources.values() def get_resource(self, function_id, resource_id): api = self.get_rest_api(function_id) + if resource_id not in api.resources: + raise ResourceIdNotFoundException resource = api.resources[resource_id] return resource def create_resource(self, function_id, parent_resource_id, path_part): + api = self.get_rest_api(function_id) + if not path_part: + # We're attempting to create the default resource, which already exists. + return api.default if not re.match("^\\{?[a-zA-Z0-9._-]+\\+?\\}?$", path_part): raise InvalidResourcePathException() - api = self.get_rest_api(function_id) child = api.add_child(path=path_part, parent_id=parent_resource_id) return child @@ -1578,7 +1656,7 @@ class APIGatewayBackend(BaseBackend): api = self.get_rest_api(function_id) methods = [ list(res.resource_methods.values()) - for res in self.list_resources(function_id) + for res in self.get_resources(function_id) ] methods = [m for sublist in methods for m in sublist] if not any(methods): diff --git a/moto/apigateway/responses.py b/moto/apigateway/responses.py index c7c1d29d7..f4bf8b2bf 100644 --- a/moto/apigateway/responses.py +++ b/moto/apigateway/responses.py @@ -4,6 +4,7 @@ from urllib.parse import unquote from moto.utilities.utils import merge_multiple_dicts from moto.core.responses import BaseResponse from .models import apigateway_backends +from .utils import deserialize_body from .exceptions import InvalidRequestInput API_KEY_SOURCES = ["AUTHORIZER", "HEADER"] @@ -56,8 +57,16 @@ class APIGatewayResponse(BaseResponse): apis = self.backend.list_apis() return 200, {}, json.dumps({"item": [api.to_dict() for api in apis]}) elif self.method == "POST": + api_doc = deserialize_body(self.body) + if api_doc: + fail_on_warnings = self._get_bool_param("failonwarnings") + rest_api = self.backend.import_rest_api(api_doc, fail_on_warnings) + + return 200, {}, json.dumps(rest_api.to_dict()) + name = self._get_param("name") description = self._get_param("description") + api_key_source = self._get_param("apiKeySource") endpoint_configuration = self._get_param("endpointConfiguration") tags = self._get_param("tags") @@ -82,6 +91,7 @@ class APIGatewayResponse(BaseResponse): policy=policy, minimum_compression_size=minimum_compression_size, ) + return 200, {}, json.dumps(rest_api.to_dict()) def __validte_rest_patch_operations(self, patch_operations): @@ -99,6 +109,15 @@ class APIGatewayResponse(BaseResponse): rest_api = self.backend.get_rest_api(function_id) elif self.method == "DELETE": rest_api = self.backend.delete_rest_api(function_id) + elif self.method == "PUT": + mode = self._get_param("mode", "merge") + fail_on_warnings = self._get_bool_param("failonwarnings", False) + + api_doc = deserialize_body(self.body) + + rest_api = self.backend.put_rest_api( + function_id, api_doc, mode, fail_on_warnings + ) elif self.method == "PATCH": patch_operations = self._get_param("patchOperations") response = self.__validte_rest_patch_operations(patch_operations) @@ -113,7 +132,7 @@ class APIGatewayResponse(BaseResponse): function_id = self.path.replace("/restapis/", "", 1).split("/")[0] if self.method == "GET": - resources = self.backend.list_resources(function_id) + resources = self.backend.get_resources(function_id) return ( 200, {}, diff --git a/moto/apigateway/utils.py b/moto/apigateway/utils.py index 083b530b0..416e10631 100644 --- a/moto/apigateway/utils.py +++ b/moto/apigateway/utils.py @@ -1,5 +1,7 @@ import random import string +import json +import yaml def create_id(): @@ -8,5 +10,17 @@ def create_id(): return "".join(str(random.choice(chars)) for x in range(size)) +def deserialize_body(body): + try: + api_doc = json.loads(body) + except json.JSONDecodeError: + api_doc = yaml.safe_load(body) + + if "openapi" in api_doc or "swagger" in api_doc: + return api_doc + + return None + + def to_path(prop): return "/" + prop diff --git a/setup.py b/setup.py index c75765e48..6b98217f2 100755 --- a/setup.py +++ b/setup.py @@ -54,6 +54,7 @@ _dep_aws_xray_sdk = "aws-xray-sdk!=0.96,>=0.93" _dep_idna = "idna<4,>=2.5" _dep_cfn_lint = "cfn-lint>=0.4.0" _dep_sshpubkeys = "sshpubkeys>=3.1.0" +_dep_openapi = "openapi-spec-validator>=0.2.8" _dep_pyparsing = "pyparsing>=3.0.0" _setuptools = "setuptools" @@ -69,6 +70,7 @@ all_extra_deps = [ _dep_cfn_lint, _dep_sshpubkeys, _dep_pyparsing, + _dep_openapi, _setuptools, ] all_server_deps = all_extra_deps + ["flask", "flask-cors"] @@ -82,7 +84,7 @@ for service_name in [ extras_per_service[service_name] = [] extras_per_service.update( { - "apigateway": [_dep_PyYAML, _dep_python_jose, _dep_python_jose_ecdsa_pin], + "apigateway": [_dep_PyYAML, _dep_python_jose, _dep_python_jose_ecdsa_pin, _dep_openapi], "apigatewayv2": [_dep_PyYAML], "appsync": [_dep_graphql], "awslambda": [_dep_docker], diff --git a/tests/test_apigateway/resources/test_api.json b/tests/test_apigateway/resources/test_api.json new file mode 100644 index 000000000..1928f3528 --- /dev/null +++ b/tests/test_apigateway/resources/test_api.json @@ -0,0 +1,102 @@ +{ + "openapi": "3.0.0", + "info": { + "description": "description from JSON file", + "version": "1", + "title": "doc" + }, + "paths": { + "/": { + "get": { + "description": "Method description.", + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Empty" + } + } + } + } + } + } + }, + "/test": { + "post": { + "description": "Method description.", + "responses": { + "201": { + "description": "201 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Empty" + } + } + } + } + } + } + } + }, + "x-amazon-apigateway-documentation": { + "version": "1.0.3", + "documentationParts": [ + { + "location": { + "type": "API" + }, + "properties": { + "description": "API description", + "info": { + "description": "API info description 4", + "version": "API info version 3" + } + } + }, + { + "location": { + "type": "METHOD", + "method": "GET" + }, + "properties": { + "description": "Method description." + } + }, + { + "location": { + "type": "MODEL", + "name": "Empty" + }, + "properties": { + "title": "Empty Schema" + } + }, + { + "location": { + "type": "RESPONSE", + "method": "GET", + "statusCode": "200" + }, + "properties": { + "description": "200 response" + } + } + ] + }, + "servers": [ + { + "url": "/" + } + ], + "components": { + "schemas": { + "Empty": { + "type": "object", + "title": "Empty Schema" + } + } + } +} \ No newline at end of file diff --git a/tests/test_apigateway/resources/test_api.yaml b/tests/test_apigateway/resources/test_api.yaml new file mode 100644 index 000000000..e13a0d78e --- /dev/null +++ b/tests/test_apigateway/resources/test_api.yaml @@ -0,0 +1,60 @@ +openapi: 3.0.0 +info: + description: description + version: '1' + title: doc +paths: + /: + get: + description: Method description. + responses: + '200': + description: 200 response + content: + application/json: + schema: + $ref: '#/components/schemas/Empty' + /test: + post: + description: Method description. + responses: + '200': + description: 200 response + content: + application/json: + schema: + $ref: '#/components/schemas/Empty' + +x-amazon-apigateway-documentation: + version: 1.0.3 + documentationParts: + - location: + type: API + properties: + description: API description + info: + description: API info description 4 + version: API info version 3 + - location: + type: METHOD + method: GET + properties: + description: Method description. + - location: + type: MODEL + name: Empty + properties: + title: Empty Schema + - location: + type: RESPONSE + method: GET + statusCode: '200' + properties: + description: 200 response +servers: + - url: / +components: + schemas: + Empty: + type: object + title: Empty Schema diff --git a/tests/test_apigateway/resources/test_api_invalid.json b/tests/test_apigateway/resources/test_api_invalid.json new file mode 100644 index 000000000..bf2df1581 --- /dev/null +++ b/tests/test_apigateway/resources/test_api_invalid.json @@ -0,0 +1,102 @@ +{ + "openapi": "3.0.0", + "info": { + "description": "description", + "version": "1", + "title": "doc" + }, + "paaths": { + "/": { + "get": { + "description": "Method description.", + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Empty" + } + } + } + } + } + } + }, + "/test": { + "post": { + "description": "Method description.", + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Empty" + } + } + } + } + } + } + } + }, + "x-amazon-apigateway-documentation": { + "version": "1.0.3", + "documentationParts": [ + { + "location": { + "type": "API" + }, + "properties": { + "description": "API description", + "info": { + "description": "API info description 4", + "version": "API info version 3" + } + } + }, + { + "location": { + "type": "METHOD", + "method": "GET" + }, + "properties": { + "description": "Method description." + } + }, + { + "location": { + "type": "MODEL", + "name": "Empty" + }, + "properties": { + "title": "Empty Schema" + } + }, + { + "location": { + "type": "RESPONSE", + "method": "GET", + "statusCode": "200" + }, + "properties": { + "description": "200 response" + } + } + ] + }, + "servers": [ + { + "url": "/" + } + ], + "components": { + "schemas": { + "Empty": { + "type": "object", + "title": "Empty Schema" + } + } + } +} \ No newline at end of file diff --git a/tests/test_apigateway/resources/test_api_invalid_version.json b/tests/test_apigateway/resources/test_api_invalid_version.json new file mode 100644 index 000000000..02260f7dc --- /dev/null +++ b/tests/test_apigateway/resources/test_api_invalid_version.json @@ -0,0 +1,102 @@ +{ + "openapi": "2.0.0", + "info": { + "description": "description", + "version": "1", + "title": "doc" + }, + "paths": { + "/": { + "get": { + "description": "Method description.", + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Empty" + } + } + } + } + } + } + }, + "/test": { + "post": { + "description": "Method description.", + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Empty" + } + } + } + } + } + } + } + }, + "x-amazon-apigateway-documentation": { + "version": "1.0.3", + "documentationParts": [ + { + "location": { + "type": "API" + }, + "properties": { + "description": "API description", + "info": { + "description": "API info description 4", + "version": "API info version 3" + } + } + }, + { + "location": { + "type": "METHOD", + "method": "GET" + }, + "properties": { + "description": "Method description." + } + }, + { + "location": { + "type": "MODEL", + "name": "Empty" + }, + "properties": { + "title": "Empty Schema" + } + }, + { + "location": { + "type": "RESPONSE", + "method": "GET", + "statusCode": "200" + }, + "properties": { + "description": "200 response" + } + } + ] + }, + "servers": [ + { + "url": "/" + } + ], + "components": { + "schemas": { + "Empty": { + "type": "object", + "title": "Empty Schema" + } + } + } +} \ No newline at end of file diff --git a/tests/test_apigateway/test_apigateway.py b/tests/test_apigateway/test_apigateway.py index 6a7dfa652..8875f5e77 100644 --- a/tests/test_apigateway/test_apigateway.py +++ b/tests/test_apigateway/test_apigateway.py @@ -367,6 +367,7 @@ def test_create_method(): "httpMethod": "GET", "authorizationType": "none", "apiKeyRequired": False, + "methodResponses": {}, "ResponseMetadata": {"HTTPStatusCode": 200}, } ) @@ -401,6 +402,7 @@ def test_create_method_apikeyrequired(): "httpMethod": "GET", "authorizationType": "none", "apiKeyRequired": True, + "methodResponses": {}, "ResponseMetadata": {"HTTPStatusCode": 200}, } ) @@ -452,6 +454,19 @@ def test_create_method_response(): response.should.equal({"ResponseMetadata": {"HTTPStatusCode": 200}}) +@mock_apigateway +def test_get_method_unknown_resource_id(): + client = boto3.client("apigateway", region_name="us-west-2") + response = client.create_rest_api(name="my_api", description="this is my api") + api_id = response["id"] + + with pytest.raises(ClientError) as ex: + client.get_method(restApiId=api_id, resourceId="sth", httpMethod="GET") + err = ex.value.response["Error"] + err["Code"].should.equal("NotFoundException") + err["Message"].should.equal("Invalid resource identifier specified") + + @mock_apigateway def test_delete_method(): client = boto3.client("apigateway", region_name="us-west-2") diff --git a/tests/test_apigateway/test_apigateway_importrestapi.py b/tests/test_apigateway/test_apigateway_importrestapi.py new file mode 100644 index 000000000..12d004538 --- /dev/null +++ b/tests/test_apigateway/test_apigateway_importrestapi.py @@ -0,0 +1,70 @@ +import boto3 +import os +import pytest + +from botocore.exceptions import ClientError +from moto import mock_apigateway + + +@mock_apigateway +def test_import_rest_api__api_is_created(): + client = boto3.client("apigateway", region_name="us-west-2") + + path = os.path.dirname(os.path.abspath(__file__)) + with open(path + "/resources/test_api.json", "rb") as api_json: + response = client.import_rest_api(body=api_json.read()) + + response.should.have.key("id") + response.should.have.key("name").which.should.equal("doc") + response.should.have.key("description").which.should.equal( + "description from JSON file" + ) + + response = client.get_rest_api(restApiId=response["id"]) + response.should.have.key("name").which.should.equal("doc") + response.should.have.key("description").which.should.equal( + "description from JSON file" + ) + + +@mock_apigateway +def test_import_rest_api__invalid_api_creates_nothing(): + client = boto3.client("apigateway", region_name="us-west-2") + + path = os.path.dirname(os.path.abspath(__file__)) + with open(path + "/resources/test_api_invalid.json", "rb") as api_json: + with pytest.raises(ClientError) as exc: + client.import_rest_api(body=api_json.read(), failOnWarnings=True) + err = exc.value.response["Error"] + err["Code"].should.equal("BadRequestException") + err["Message"].should.equal( + "Failed to parse the uploaded OpenAPI document due to: 'paths' is a required property" + ) + + client.get_rest_apis().should.have.key("items").length_of(0) + + +@mock_apigateway +def test_import_rest_api__methods_are_created(): + client = boto3.client("apigateway", region_name="us-east-1") + + path = os.path.dirname(os.path.abspath(__file__)) + with open(path + "/resources/test_api.json", "rb") as api_json: + resp = client.import_rest_api(body=api_json.read()) + api_id = resp["id"] + + resources = client.get_resources(restApiId=api_id) + root_id = [res for res in resources["items"] if res["path"] == "/"][0]["id"] + + # We have a GET-method + resp = client.get_method(restApiId=api_id, resourceId=root_id, httpMethod="GET") + resp["methodResponses"].should.equal({"200": {"statusCode": "200"}}) + + # We have a POST on /test + test_path_id = [res for res in resources["items"] if res["path"] == "/test"][0][ + "id" + ] + resp = client.get_method( + restApiId=api_id, resourceId=test_path_id, httpMethod="POST" + ) + resp["methodResponses"].should.equal({"201": {"statusCode": "201"}}) diff --git a/tests/test_apigateway/test_apigateway_putrestapi.py b/tests/test_apigateway/test_apigateway_putrestapi.py new file mode 100644 index 000000000..6bd63cf8b --- /dev/null +++ b/tests/test_apigateway/test_apigateway_putrestapi.py @@ -0,0 +1,217 @@ +import boto3 +import os +import pytest + +from botocore.exceptions import ClientError +from moto import mock_apigateway + + +@mock_apigateway +def test_put_rest_api__api_details_are_persisted(): + client = boto3.client("apigateway", region_name="us-west-2") + + response = client.create_rest_api(name="my_api", description="this is my api") + api_id = response["id"] + + path = os.path.dirname(os.path.abspath(__file__)) + with open(path + "/resources/test_api.json", "rb") as api_json: + response = client.put_rest_api( + restApiId=api_id, + mode="overwrite", + failOnWarnings=True, + body=api_json.read(), + ) + + response.should.have.key("id").which.should.equal(api_id) + response.should.have.key("name").which.should.equal("my_api") + response.should.have.key("description").which.should.equal("this is my api") + + +@mock_apigateway +def test_put_rest_api__methods_are_created(): + client = boto3.client("apigateway", region_name="us-east-2") + + response = client.create_rest_api(name="my_api", description="this is my api") + api_id = response["id"] + + path = os.path.dirname(os.path.abspath(__file__)) + with open(path + "/resources/test_api.json", "rb") as api_json: + client.put_rest_api(restApiId=api_id, body=api_json.read()) + + resources = client.get_resources(restApiId=api_id) + root_id = [res for res in resources["items"] if res["path"] == "/"][0]["id"] + + # We have a GET-method + resp = client.get_method(restApiId=api_id, resourceId=root_id, httpMethod="GET") + resp["methodResponses"].should.equal({"200": {"statusCode": "200"}}) + + # We have a POST on /test + test_path_id = [res for res in resources["items"] if res["path"] == "/test"][0][ + "id" + ] + resp = client.get_method( + restApiId=api_id, resourceId=test_path_id, httpMethod="POST" + ) + resp["methodResponses"].should.equal({"201": {"statusCode": "201"}}) + + +@mock_apigateway +def test_put_rest_api__existing_methods_are_overwritten(): + client = boto3.client("apigateway", region_name="us-east-2") + + response = client.create_rest_api(name="my_api", description="this is my api") + api_id = response["id"] + + resources = client.get_resources(restApiId=api_id) + root_id = [resource for resource in resources["items"] if resource["path"] == "/"][ + 0 + ]["id"] + + client.put_method( + restApiId=api_id, + resourceId=root_id, + httpMethod="POST", + authorizationType="none", + ) + + response = client.get_method( + restApiId=api_id, resourceId=root_id, httpMethod="POST" + ) + response.should.have.key("httpMethod").equals("POST") + + path = os.path.dirname(os.path.abspath(__file__)) + with open(path + "/resources/test_api.json", "rb") as api_json: + client.put_rest_api( + restApiId=api_id, + mode="overwrite", + failOnWarnings=True, + body=api_json.read(), + ) + + # Since we chose mode=overwrite, the root_id is different + resources = client.get_resources(restApiId=api_id) + new_root_id = [ + resource for resource in resources["items"] if resource["path"] == "/" + ][0]["id"] + + new_root_id.shouldnt.equal(root_id) + + # Our POST-method should be gone + with pytest.raises(ClientError) as exc: + client.get_method(restApiId=api_id, resourceId=new_root_id, httpMethod="POST") + err = exc.value.response["Error"] + err["Code"].should.equal("NotFoundException") + + # We just have a GET-method, as defined in the JSON + client.get_method(restApiId=api_id, resourceId=new_root_id, httpMethod="GET") + + +@mock_apigateway +def test_put_rest_api__existing_methods_still_exist(): + client = boto3.client("apigateway", region_name="us-east-2") + + response = client.create_rest_api(name="my_api", description="this is my api") + api_id = response["id"] + + resources = client.get_resources(restApiId=api_id) + root_id = [resource for resource in resources["items"] if resource["path"] == "/"][ + 0 + ]["id"] + + client.put_method( + restApiId=api_id, + resourceId=root_id, + httpMethod="POST", + authorizationType="none", + ) + + path = os.path.dirname(os.path.abspath(__file__)) + with open(path + "/resources/test_api.json", "rb") as api_json: + client.put_rest_api( + restApiId=api_id, + mode="merge", + failOnWarnings=True, + body=api_json.read(), + ) + + response = client.get_method( + restApiId=api_id, resourceId=root_id, httpMethod="POST" + ) + response.should.have.key("httpMethod").equals("POST") + + +@mock_apigateway +def test_put_rest_api__fail_on_invalid_spec(): + client = boto3.client("apigateway", region_name="us-east-2") + + response = client.create_rest_api(name="my_api", description="this is my api") + api_id = response["id"] + + path = os.path.dirname(os.path.abspath(__file__)) + with open(path + "/resources/test_api_invalid.json", "rb") as api_json: + with pytest.raises(ClientError) as exc: + client.put_rest_api( + restApiId=api_id, failOnWarnings=True, body=api_json.read() + ) + err = exc.value.response["Error"] + err["Code"].should.equal("BadRequestException") + err["Message"].should.equal( + "Failed to parse the uploaded OpenAPI document due to: 'paths' is a required property" + ) + + +@mock_apigateway +def test_put_rest_api__fail_on_invalid_version(): + client = boto3.client("apigateway", region_name="us-east-2") + + response = client.create_rest_api(name="my_api", description="this is my api") + api_id = response["id"] + + path = os.path.dirname(os.path.abspath(__file__)) + with open(path + "/resources/test_api_invalid_version.json", "rb") as api_json: + with pytest.raises(ClientError) as exc: + client.put_rest_api( + restApiId=api_id, failOnWarnings=True, body=api_json.read() + ) + err = exc.value.response["Error"] + err["Code"].should.equal("BadRequestException") + err["Message"].should.equal("Only OpenAPI 3.x.x are currently supported") + + +@mock_apigateway +def test_put_rest_api__fail_on_invalid_mode(): + client = boto3.client("apigateway", region_name="us-east-2") + + response = client.create_rest_api(name="my_api", description="this is my api") + api_id = response["id"] + + path = os.path.dirname(os.path.abspath(__file__)) + with open(path + "/resources/test_api.json", "rb") as api_json: + with pytest.raises(ClientError) as exc: + client.put_rest_api(restApiId=api_id, mode="unknown", body=api_json.read()) + err = exc.value.response["Error"] + err["Code"].should.equal("BadRequestException") + err["Message"].should.equal( + 'Enumeration value of OpenAPI import mode must be "overwrite" or "merge"' + ) + + +@mock_apigateway +def test_put_rest_api__as_yaml(): + client = boto3.client("apigateway", region_name="us-west-2") + + response = client.create_rest_api(name="my_api", description="this is my api") + api_id = response["id"] + + path = os.path.dirname(os.path.abspath(__file__)) + with open(path + "/resources/test_api.yaml", "rb") as api_yaml: + response = client.put_rest_api( + restApiId=api_id, + mode="overwrite", + failOnWarnings=True, + body=api_yaml.read(), + ) + + response.should.have.key("id").which.should.equal(api_id) + response.should.have.key("name").which.should.equal("my_api") + response.should.have.key("description").which.should.equal("this is my api")