Repository: Miserlou/Zappa

Issue:
Feature request: specify endpoint_url to clients (for testing with e.g. localstack)
## Context
I'd like to be able to test my Zappa deploys up against various AWS-emulators or AWS-alikes - for instance [localstack](https://github.com/localstack/localstack).  To do so, there needs to be a way to specify the `endpoint_url` to all the calls to `boto_session.client()`.  

Note that this is more for testing systems than individual deploys: For instance, producer/consumer lambdas each on the end of SQS or SNS.

## Expected Behavior

I need a way to specify the `endpoint_url` to run against localstack for testing.  

## Actual Behavior

Can't do anything like it :(

## Possible Fix

I'd suggest putting the setting in `zappa_settings.json`.  I could then make a 'local' target just like the normal 'dev' and 'production' targets.

Feature request: specify endpoint_url to clients (for testing with e.g. localstack)
## Context
I'd like to be able to test my Zappa deploys up against various AWS-emulators or AWS-alikes - for instance [localstack](https://github.com/localstack/localstack).  To do so, there needs to be a way to specify the `endpoint_url` to all the calls to `boto_session.client()`.  

Note that this is more for testing systems than individual deploys: For instance, producer/consumer lambdas each on the end of SQS or SNS.

## Expected Behavior

I need a way to specify the `endpoint_url` to run against localstack for testing.  

## Actual Behavior

Can't do anything like it :(

## Possible Fix

I'd suggest putting the setting in `zappa_settings.json`.  I could then make a 'local' target just like the normal 'dev' and 'production' targets.



Discussion:
Is this something that can be configured during boto session setup, or does it need to be included for every boto call? Good feature, would accept PR happily.

Suggest `aws_endpoint_url` for the setting key.
It's the `endpoint_url` argument to the botocore `Session.client` call (documented [here](https://boto3.readthedocs.io/en/latest/reference/core/session.html?highlight=botocore.client.Config#boto3.session.Session.client)).

So... not Session setup, but Client setup.
and actually... ugh. Every client potentially needs a different endpoint. 
How about a dict?  aws_endpoint_urls: { servicename: url } ?  so like:
```
    ...
    'aws_endpoint_urls': {
        's3': 'http://localhost:1234',
        'lambda': 'http://localhost:1235',
        ...
    }
    ...

```
Makes sense
Is this something that can be configured during boto session setup, or does it need to be included for every boto call? Good feature, would accept PR happily.

Suggest `aws_endpoint_url` for the setting key.
It's the `endpoint_url` argument to the botocore `Session.client` call (documented [here](https://boto3.readthedocs.io/en/latest/reference/core/session.html?highlight=botocore.client.Config#boto3.session.Session.client)).

So... not Session setup, but Client setup.
and actually... ugh. Every client potentially needs a different endpoint. 
How about a dict?  aws_endpoint_urls: { servicename: url } ?  so like:
```
    ...
    'aws_endpoint_urls': {
        's3': 'http://localhost:1234',
        'lambda': 'http://localhost:1235',
        ...
    }
    ...

```
Makes sense

Resolving patch:
diff --git a/zappa/cli.py b/zappa/cli.py
--- a/zappa/cli.py
+++ b/zappa/cli.py
@@ -1949,7 +1949,8 @@ def load_settings(self, settings_file=None, session=None):
                             load_credentials=self.load_credentials,
                             desired_role_name=desired_role_name,
                             tags=self.tags,
-                            runtime=self.runtime
+                            runtime=self.runtime,
+                            endpoint_urls=self.zappa_settings.get('aws_endpoint_urls',{})
                         )
 
         for setting in CUSTOM_SETTINGS:
diff --git a/zappa/core.py b/zappa/core.py
--- a/zappa/core.py
+++ b/zappa/core.py
@@ -216,6 +216,7 @@ def __init__(self,
             desired_role_name=None,
             runtime='python2.7',
             tags=(),
+            endpoint_urls={}
         ):
         # Set aws_region to None to use the system's region instead
         if aws_region is None:
@@ -235,6 +236,7 @@ def __init__(self,
         else:
             self.manylinux_wheel_file_suffix = 'cp36m-manylinux1_x86_64.whl'
 
+        self.endpoint_urls = endpoint_urls
 
         # Some common invokations, such as DB migrations,
         # can take longer than the default.
@@ -253,27 +255,40 @@ def __init__(self,
             self.load_credentials(boto_session, profile_name)
 
             # Initialize clients
-            self.s3_client = self.boto_session.client('s3')
-            self.lambda_client = self.boto_session.client('lambda', config=long_config)
-            self.events_client = self.boto_session.client('events')
-            self.apigateway_client = self.boto_session.client('apigateway')
+            self.s3_client = self.boto_client('s3')
+            self.lambda_client = self.boto_client('lambda', config=long_config)
+            self.events_client = self.boto_client('events')
+            self.apigateway_client = self.boto_client('apigateway')
             # acm certificates need to be created from us-east-1 to be used by API gateway
             east_config = botocore.client.Config(region_name='us-east-1')
-            self.acm_client = self.boto_session.client('acm', config=east_config)
-            self.logs_client = self.boto_session.client('logs')
-            self.iam_client = self.boto_session.client('iam')
-            self.iam = self.boto_session.resource('iam')
-            self.cloudwatch = self.boto_session.client('cloudwatch')
-            self.route53 = self.boto_session.client('route53')
-            self.sns_client = self.boto_session.client('sns')
-            self.cf_client = self.boto_session.client('cloudformation')
-            self.dynamodb_client = self.boto_session.client('dynamodb')
+            self.acm_client = self.boto_client('acm', config=east_config)
+            self.logs_client = self.boto_client('logs')
+            self.iam_client = self.boto_client('iam')
+            self.iam = self.boto_resource('iam')
+            self.cloudwatch = self.boto_client('cloudwatch')
+            self.route53 = self.boto_client('route53')
+            self.sns_client = self.boto_client('sns')
+            self.cf_client = self.boto_client('cloudformation')
+            self.dynamodb_client = self.boto_client('dynamodb')
 
         self.tags = tags
         self.cf_template = troposphere.Template()
         self.cf_api_resources = []
         self.cf_parameters = {}
 
+    def configure_boto_session_method_kwargs(self, service, kw):
+        if service in self.endpoint_urls and not 'endpoint_url' in kw:
+            kw['endpoint_url'] = self.endpoint_urls[service]
+        return kw
+
+    def boto_client(self, service, *args, **kwargs):
+        """A wrapper to apply configuration options to boto clients"""
+        return self.boto_session.client(service, *args, **self.configure_boto_session_method_kwargs(service, kwargs))
+
+    def boto_resource(self, service, *args, **kwargs):
+        """A wrapper to apply configuration options to boto resources"""
+        return self.boto_session.resource(service, *args, **self.configure_boto_session_method_kwargs(service, kwargs))
+
     def cache_param(self, value):
         '''Returns a troposphere Ref to a value cached as a parameter.'''
 


Test patch: