Repository: localstack/localstack

Issue:
bug: ApiGateway Model schema stored in Wrong JSON format
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Current Behavior

APiGateway Model that being created by CDK project stores the JSON schema with a single quotes instead of double quotes which leading to an error in parsing this json object. 

you can refere to this article to see the steps 

https://dev.to/dvddpl/reduce-lambda-invocations-and-code-boilerplate-by-adding-a-json-schema-validator-to-your-api-gateway-15af

and it works fine on AWS account 

also you can look on the attached image how localstack stores the JSON object with a single quotes, I tried to edit it manually through UI but it return it back with single quotes

![Screen Shot 2022-10-18 at 4 28 08 PM](https://user-images.githubusercontent.com/106184681/196769273-0834865a-2fb3-4efc-b3a9-9da6b2ad511d.png)

### Expected Behavior

store the json schema in correct JSON format

### How are you starting LocalStack?

With the `localstack` script

### Steps To Reproduce

#### How are you starting localstack (e.g., `bin/localstack` command, arguments, or `docker-compose.yml`)

    localstack start

#### Client commands (e.g., AWS SDK code snippet, or sequence of "awslocal" commands)

    cdklocal deploy


### Environment

```markdown
- OS:OSX
- LocalStack: 1.0.4
```


### Anything else?

_No response_


Discussion:
I've run into this issue too.  It would be great to have a fix for this.
Hi @adrianluisgonzalez, I'm going to work on this today, so we should have a fix ready pretty soon! Thanks for your report too. 

Resolving patch:
diff --git a/localstack/services/apigateway/invocations.py b/localstack/services/apigateway/invocations.py
--- a/localstack/services/apigateway/invocations.py
+++ b/localstack/services/apigateway/invocations.py
@@ -62,11 +62,11 @@ def is_request_valid(self) -> bool:
             return True
 
         resource_methods = self.context.resource["resourceMethods"]
-        if self.context.method not in resource_methods:
+        if self.context.method not in resource_methods and "ANY" not in resource_methods:
             return True
 
         # check if there is validator for the resource
-        resource = resource_methods[self.context.method]
+        resource = resource_methods.get(self.context.method, resource_methods.get("ANY", {}))
         if not (resource.get("requestValidatorId") or "").strip():
             return True
 
diff --git a/localstack/services/apigateway/provider.py b/localstack/services/apigateway/provider.py
--- a/localstack/services/apigateway/provider.py
+++ b/localstack/services/apigateway/provider.py
@@ -457,7 +457,26 @@ def get_method(
     ) -> Method:
         response: Method = call_moto(context)
         remove_empty_attributes_from_method(response)
-        remove_empty_attributes_from_integration(response.get("methodIntegration"))
+        if method_integration := response.get("methodIntegration"):
+            remove_empty_attributes_from_integration(method_integration)
+            # moto will not return `responseParameters` field if it's not truthy, but AWS will return an empty dict
+            # if it was set to an empty dict
+            if "responseParameters" not in method_integration:
+                moto_rest_api = get_moto_rest_api(context, rest_api_id)
+                moto_resource = moto_rest_api.resources[resource_id]
+                moto_method_integration = moto_resource.resource_methods[
+                    http_method
+                ].method_integration
+                if moto_method_integration.integration_responses:
+                    for (
+                        status_code,
+                        integration_response,
+                    ) in moto_method_integration.integration_responses.items():
+                        if integration_response.response_parameters == {}:
+                            method_integration["integrationResponses"][str(status_code)][
+                                "responseParameters"
+                            ] = {}
+
         return response
 
     def put_method(
diff --git a/localstack/services/cloudformation/models/apigateway.py b/localstack/services/cloudformation/models/apigateway.py
--- a/localstack/services/cloudformation/models/apigateway.py
+++ b/localstack/services/cloudformation/models/apigateway.py
@@ -397,6 +397,7 @@ def get_params(
                 "requestParameters",
                 "requestModels",
                 "requestValidatorId",
+                "operationName",
             ]
             result = select_attributes(result, param_names)
             result["requestModels"] = result.get("requestModels") or {}
@@ -823,8 +824,7 @@ def fetch_state(self, stack_name, resources):
     @staticmethod
     def add_defaults(resource, stack_name: str):
         props = resource.get("Properties", {})
-        role_name = props.get("Name")
-        if not role_name:
+        if not props.get("Name"):
             props["Name"] = generate_default_name(stack_name, resource["LogicalResourceId"])
 
         content_type = props.get("contentType")
@@ -834,7 +834,12 @@ def add_defaults(resource, stack_name: str):
     @staticmethod
     def get_deploy_templates():
         def _handle_result(result: dict, logical_resource_id: str, resource: dict):
-            resource["PhysicalResourceId"] = result["id"]
+            resource["PhysicalResourceId"] = result["name"]
+
+        def _jsonify_schema(
+            properties: dict, logical_resource_id: str, resource: dict, stack_name: str
+        ) -> str:
+            return json.dumps(properties.get("Schema", {}))
 
         return {
             "create": {
@@ -842,7 +847,7 @@ def _handle_result(result: dict, logical_resource_id: str, resource: dict):
                 "parameters": {
                     "name": "Name",
                     "restApiId": "RestApiId",
-                    "schema": "Schema",
+                    "schema": _jsonify_schema,
                     "contentType": "ContentType",
                 },
                 "types": {"schema": str},


Test patch:
diff --git a/tests/integration/cloudformation/resources/test_apigateway.py b/tests/integration/cloudformation/resources/test_apigateway.py
--- a/tests/integration/cloudformation/resources/test_apigateway.py
+++ b/tests/integration/cloudformation/resources/test_apigateway.py
@@ -153,7 +153,16 @@ def test_url_output(tmp_http_server, deploy_cfn_template):
     assert f"https://{api_id}.execute-api.{constants.LOCALHOST_HOSTNAME}:4566" in api_url
 
 
-def test_cfn_with_apigateway_resources(deploy_cfn_template, aws_client):
+@pytest.mark.aws_validated
+@pytest.mark.skip_snapshot_verify(
+    paths=[
+        "$.get-method-post.methodIntegration.connectionType",  # TODO: maybe because this is a MOCK integration
+    ]
+)
+def test_cfn_with_apigateway_resources(deploy_cfn_template, aws_client, snapshot):
+    snapshot.add_transformer(snapshot.transform.apigateway_api())
+    snapshot.add_transformer(snapshot.transform.key_value("cacheNamespace"))
+
     stack = deploy_cfn_template(
         template_path=os.path.join(os.path.dirname(__file__), "../../templates/template35.yaml")
     )
@@ -173,17 +182,19 @@ def test_cfn_with_apigateway_resources(deploy_cfn_template, aws_client):
 
     assert len(resources) == 1
 
-    # assert request parameter is present in resource method
-    assert resources[0]["resourceMethods"]["POST"]["requestParameters"] == {
-        "method.request.path.account": True
-    }
-    models = [
-        model
-        for model in aws_client.apigateway.get_models(restApiId=api_id)["items"]
-        if stack.stack_name in model["name"]
-    ]
+    resp = aws_client.apigateway.get_method(
+        restApiId=api_id, resourceId=resources[0]["id"], httpMethod="POST"
+    )
+    snapshot.match("get-method-post", resp)
+
+    models = aws_client.apigateway.get_models(restApiId=api_id)
+    models["items"].sort(key=itemgetter("name"))
+    snapshot.match("get-models", models)
 
-    assert len(models) == 2
+    schemas = [model["schema"] for model in models["items"]]
+    for schema in schemas:
+        # assert that we can JSON load the schema, and that the schema is a valid JSON
+        assert isinstance(json.loads(schema), dict)
 
     stack.destroy()
 
@@ -195,6 +206,57 @@ def test_cfn_with_apigateway_resources(deploy_cfn_template, aws_client):
     assert not apis
 
 
+@pytest.mark.aws_validated
+@pytest.mark.skip_snapshot_verify(
+    paths=["$.get-resources.items..resourceMethods.ANY"]  # TODO: empty in AWS
+)
+def test_cfn_deploy_apigateway_models(deploy_cfn_template, snapshot, aws_client):
+    snapshot.add_transformer(snapshot.transform.apigateway_api())
+    stack = deploy_cfn_template(
+        template_path=os.path.join(
+            os.path.dirname(__file__), "../../templates/apigateway_models.json"
+        )
+    )
+
+    api_id = stack.outputs["RestApiId"]
+
+    resources = aws_client.apigateway.get_resources(restApiId=api_id)
+    resources["items"].sort(key=itemgetter("path"))
+    snapshot.match("get-resources", resources)
+
+    models = aws_client.apigateway.get_models(restApiId=api_id)
+    models["items"].sort(key=itemgetter("name"))
+    snapshot.match("get-models", models)
+
+    request_validators = aws_client.apigateway.get_request_validators(restApiId=api_id)
+    snapshot.match("get-request-validators", request_validators)
+
+    for resource in resources["items"]:
+        if resource["path"] == "/validated":
+            resp = aws_client.apigateway.get_method(
+                restApiId=api_id, resourceId=resource["id"], httpMethod="ANY"
+            )
+            snapshot.match("get-method-any", resp)
+
+    # construct API endpoint URL
+    url = api_invoke_url(api_id, stage="local", path="/validated")
+
+    # invoke API endpoint, assert results
+    valid_data = {"string_field": "string", "integer_field": 123456789}
+
+    result = requests.post(url, json=valid_data)
+    assert result.ok
+
+    # invoke API endpoint, assert results
+    invalid_data = {"string_field": "string"}
+
+    result = requests.post(url, json=invalid_data)
+    assert result.status_code == 400
+
+    result = requests.get(url)
+    assert result.status_code == 400
+
+
 @pytest.mark.aws_validated
 def test_cfn_deploy_apigateway_integration(deploy_cfn_template, snapshot, aws_client):
     snapshot.add_transformer(snapshot.transform.key_value("cacheNamespace"))
diff --git a/tests/integration/cloudformation/resources/test_apigateway.snapshot.json b/tests/integration/cloudformation/resources/test_apigateway.snapshot.json
--- a/tests/integration/cloudformation/resources/test_apigateway.snapshot.json
+++ b/tests/integration/cloudformation/resources/test_apigateway.snapshot.json
@@ -166,5 +166,275 @@
         }
       }
     }
+  },
+  "tests/integration/cloudformation/resources/test_apigateway.py::test_cfn_deploy_apigateway_models": {
+    "recorded-date": "05-07-2023, 20:11:14",
+    "recorded-content": {
+      "get-resources": {
+        "items": [
+          {
+            "id": "<id:1>",
+            "path": "/"
+          },
+          {
+            "id": "<id:2>",
+            "parentId": "<id:1>",
+            "path": "/validated",
+            "pathPart": "validated",
+            "resourceMethods": {
+              "ANY": {}
+            }
+          }
+        ],
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 200
+        }
+      },
+      "get-models": {
+        "items": [
+          {
+            "contentType": "application/json",
+            "description": "This is a default empty schema model",
+            "id": "<id:3>",
+            "name": "<name:1>",
+            "schema": {
+              "$schema": "http://json-schema.org/draft-04/schema#",
+              "title": "<name:1> Schema",
+              "type": "object"
+            }
+          },
+          {
+            "contentType": "application/json",
+            "description": "This is a default error schema model",
+            "id": "<id:4>",
+            "name": "<name:2>",
+            "schema": {
+              "$schema": "http://json-schema.org/draft-04/schema#",
+              "title": "<name:2> Schema",
+              "type": "object",
+              "properties": {
+                "message": {
+                  "type": "string"
+                }
+              }
+            }
+          },
+          {
+            "contentType": "application/json",
+            "id": "<id:5>",
+            "name": "<name:3>",
+            "schema": {
+              "$schema": "http://json-schema.org/draft-04/schema#",
+              "title": "<name:3>",
+              "type": "object",
+              "properties": {
+                "integer_field": {
+                  "type": "number"
+                },
+                "string_field": {
+                  "type": "string"
+                }
+              },
+              "required": [
+                "string_field",
+                "integer_field"
+              ]
+            }
+          }
+        ],
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 200
+        }
+      },
+      "get-request-validators": {
+        "items": [
+          {
+            "id": "<id:6>",
+            "name": "<name:4>",
+            "validateRequestBody": true,
+            "validateRequestParameters": false
+          }
+        ],
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 200
+        }
+      },
+      "get-method-any": {
+        "apiKeyRequired": false,
+        "authorizationType": "NONE",
+        "httpMethod": "ANY",
+        "methodIntegration": {
+          "cacheKeyParameters": [],
+          "cacheNamespace": "<id:2>",
+          "integrationResponses": {
+            "200": {
+              "responseParameters": {},
+              "responseTemplates": {},
+              "statusCode": "200"
+            }
+          },
+          "passthroughBehavior": "NEVER",
+          "requestParameters": {},
+          "requestTemplates": {
+            "application/json": {
+              "statusCode": 200
+            }
+          },
+          "timeoutInMillis": 29000,
+          "type": "MOCK"
+        },
+        "methodResponses": {
+          "200": {
+            "responseModels": {},
+            "responseParameters": {},
+            "statusCode": "200"
+          }
+        },
+        "requestModels": {
+          "application/json": "<name:3>"
+        },
+        "requestParameters": {},
+        "requestValidatorId": "<id:6>",
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 200
+        }
+      }
+    }
+  },
+  "tests/integration/cloudformation/resources/test_apigateway.py::test_cfn_with_apigateway_resources": {
+    "recorded-date": "05-07-2023, 20:54:14",
+    "recorded-content": {
+      "get-method-post": {
+        "apiKeyRequired": false,
+        "authorizationType": "NONE",
+        "httpMethod": "POST",
+        "methodIntegration": {
+          "cacheKeyParameters": [],
+          "cacheNamespace": "<cache-namespace:1>",
+          "integrationResponses": {
+            "202": {
+              "responseParameters": {},
+              "responseTemplates": {
+                "application/json": {
+                  "operation": "celeste_account_create",
+                  "data": {
+                    "key": "123e4567-e89b-12d3-a456-426614174000",
+                    "secret": "123e4567-e89b-12d3-a456-426614174000"
+                  }
+                }
+              },
+              "selectionPattern": "2\\d{2}",
+              "statusCode": "202"
+            },
+            "500": {
+              "responseParameters": {},
+              "responseTemplates": {
+                "application/json": {
+                  "message": "Unknown <name:2>"
+                }
+              },
+              "selectionPattern": "5\\d{2}",
+              "statusCode": "500"
+            }
+          },
+          "passthroughBehavior": "WHEN_NO_TEMPLATES",
+          "requestParameters": {},
+          "requestTemplates": {
+            "application/json": "<name:3>"
+          },
+          "timeoutInMillis": 29000,
+          "type": "MOCK"
+        },
+        "methodResponses": {
+          "202": {
+            "responseModels": {
+              "application/json": "<name:4>"
+            },
+            "responseParameters": {},
+            "statusCode": "202"
+          },
+          "500": {
+            "responseModels": {
+              "application/json": "<name:4>"
+            },
+            "responseParameters": {},
+            "statusCode": "500"
+          }
+        },
+        "operationName": "create_account",
+        "requestModels": {},
+        "requestParameters": {
+          "method.request.path.account": true
+        },
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 200
+        }
+      },
+      "get-models": {
+        "items": [
+          {
+            "contentType": "application/json",
+            "description": "This is a default empty schema model",
+            "id": "<id:1>",
+            "name": "<name:1>",
+            "schema": {
+              "$schema": "http://json-schema.org/draft-04/schema#",
+              "title": "<name:1> Schema",
+              "type": "object"
+            }
+          },
+          {
+            "contentType": "application/json",
+            "description": "This is a default error schema model",
+            "id": "<id:2>",
+            "name": "<name:2>",
+            "schema": {
+              "$schema": "http://json-schema.org/draft-04/schema#",
+              "title": "<name:2> Schema",
+              "type": "object",
+              "properties": {
+                "message": {
+                  "type": "string"
+                }
+              }
+            }
+          },
+          {
+            "contentType": "application/json",
+            "id": "<id:3>",
+            "name": "<name:3>",
+            "schema": {
+              "$schema": "http://json-schema.org/draft-04/schema#",
+              "title": "AccountCreate",
+              "type": "object",
+              "properties": {
+                "field": {
+                  "type": "string"
+                },
+                "email": {
+                  "format": "email",
+                  "type": "string"
+                }
+              }
+            }
+          },
+          {
+            "contentType": "application/json",
+            "id": "<id:4>",
+            "name": "<name:4>",
+            "schema": {}
+          }
+        ],
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 200
+        }
+      }
+    }
   }
 }
diff --git a/tests/integration/templates/apigateway_models.json b/tests/integration/templates/apigateway_models.json
new file mode 100644
--- /dev/null
+++ b/tests/integration/templates/apigateway_models.json
@@ -0,0 +1,156 @@
+{
+ "Resources": {
+  "apiC8550315": {
+   "Type": "AWS::ApiGateway::RestApi",
+   "Properties": {
+    "Name": "test-cdk-models-json"
+   },
+   "Metadata": {
+    "aws:cdk:path": "ValidatorExampleStack/api/Resource"
+   }
+  },
+  "apiDeployment149F12947655b29bb65dd29c4fa41b0e0ff8358d": {
+   "Type": "AWS::ApiGateway::Deployment",
+   "Properties": {
+    "RestApiId": {
+     "Ref": "apiC8550315"
+    },
+    "Description": "Automatically created by the RestApi construct"
+   },
+   "DependsOn": [
+    "apivalidatedANY5D963C0D",
+    "apivalidatedA453B5EB",
+    "jsonmodelCE5E769A",
+    "requestvalidatorBE19E57D"
+   ],
+   "Metadata": {
+    "aws:cdk:path": "ValidatorExampleStack/api/Deployment/Resource"
+   }
+  },
+  "apiDeploymentStagelocal2DF7037D": {
+   "Type": "AWS::ApiGateway::Stage",
+   "Properties": {
+    "RestApiId": {
+     "Ref": "apiC8550315"
+    },
+    "DeploymentId": {
+     "Ref": "apiDeployment149F12947655b29bb65dd29c4fa41b0e0ff8358d"
+    },
+    "StageName": "local"
+   },
+   "Metadata": {
+    "aws:cdk:path": "ValidatorExampleStack/api/DeploymentStage.local/Resource"
+   }
+  },
+  "apivalidatedA453B5EB": {
+   "Type": "AWS::ApiGateway::Resource",
+   "Properties": {
+    "ParentId": {
+     "Fn::GetAtt": [
+      "apiC8550315",
+      "RootResourceId"
+     ]
+    },
+    "PathPart": "validated",
+    "RestApiId": {
+     "Ref": "apiC8550315"
+    }
+   },
+   "Metadata": {
+    "aws:cdk:path": "ValidatorExampleStack/api/Default/validated/Resource"
+   }
+  },
+  "apivalidatedANY5D963C0D": {
+   "Type": "AWS::ApiGateway::Method",
+   "Properties": {
+    "HttpMethod": "ANY",
+    "ResourceId": {
+     "Ref": "apivalidatedA453B5EB"
+    },
+    "RestApiId": {
+     "Ref": "apiC8550315"
+    },
+    "AuthorizationType": "NONE",
+    "Integration": {
+     "IntegrationResponses": [
+      {
+       "StatusCode": "200"
+      }
+     ],
+     "PassthroughBehavior": "NEVER",
+     "RequestTemplates": {
+      "application/json": "{ \"statusCode\": 200 }"
+     },
+     "Type": "MOCK"
+    },
+    "MethodResponses": [
+     {
+      "StatusCode": "200"
+     }
+    ],
+    "RequestModels": {
+     "application/json": {
+      "Ref": "jsonmodelCE5E769A"
+     }
+    },
+    "RequestValidatorId": {
+     "Ref": "requestvalidatorBE19E57D"
+    }
+   },
+   "Metadata": {
+    "aws:cdk:path": "ValidatorExampleStack/api/Default/validated/ANY/Resource"
+   }
+  },
+  "requestvalidatorBE19E57D": {
+   "Type": "AWS::ApiGateway::RequestValidator",
+   "Properties": {
+    "RestApiId": {
+     "Ref": "apiC8550315"
+    },
+    "Name": "require-valid-body",
+    "ValidateRequestBody": true,
+    "ValidateRequestParameters": false
+   },
+   "Metadata": {
+    "aws:cdk:path": "ValidatorExampleStack/request-validator/Resource"
+   }
+  },
+  "jsonmodelCE5E769A": {
+   "Type": "AWS::ApiGateway::Model",
+   "Properties": {
+    "RestApiId": {
+     "Ref": "apiC8550315"
+    },
+    "ContentType": "application/json",
+    "Name": "TestModel",
+    "Schema": {
+     "title": "TestModel",
+     "$schema": "http://json-schema.org/draft-04/schema#",
+     "type": "object",
+     "properties": {
+      "string_field": {
+       "type": "string"
+      },
+      "integer_field": {
+       "type": "number"
+      }
+     },
+     "required": [
+      "string_field",
+      "integer_field"
+     ]
+    }
+   },
+   "Metadata": {
+    "aws:cdk:path": "ValidatorExampleStack/json-model/Resource"
+   }
+  }
+ },
+ "Outputs": {
+  "RestApiId": {
+   "Value": {
+    "Ref": "apiC8550315"
+   }
+  }
+ }
+}
diff --git a/tests/integration/templates/template35.yaml b/tests/integration/templates/template35.yaml
--- a/tests/integration/templates/template35.yaml
+++ b/tests/integration/templates/template35.yaml
@@ -37,7 +37,7 @@ Resources:
       - CreateAccount
     Properties:
       RestApiId: !Ref Gateway
-  GatewayEmptyModel:
+  DefaultModel:
     Type: AWS::ApiGateway::Model
     Properties:
       ContentType: 'application/json'
@@ -62,7 +62,7 @@ Resources:
         title: AccountCreate
         type: object
         properties:
-          name:
+          field:
             type: string
           email:
             type: string
@@ -93,10 +93,10 @@ Resources:
         TimeoutInMillis: 29000
       MethodResponses:
         - ResponseModels:
-            application/json: !Ref GatewayEmptyModel
+            application/json: !Ref DefaultModel
           StatusCode: '202'
         - ResponseModels:
-            application/json: !Ref GatewayEmptyModel
+            application/json: !Ref DefaultModel
           StatusCode: '500'
       OperationName: 'create_account'
       ResourceId: !Ref CreateAccountResource