Skip to content

Commit f186d3e

Browse files
whummerclaude
andauthored
add proxy tests for API Gateway v1/v2 (#119)
* add proxy tests for the API Gateway service Tests cover: - REST API requests (create, get, update) - Resources and methods (get_method, get_method_response, get_integration) - Deployments and stages - Read-only mode - Selective resource matching and error handling Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * add proxy tests for the API Gateway v2 service Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * clean up API Gateway v1/v2 proxy tests: remove logger, use pytest.raises Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * bump version of aws-proxy extension --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 6888e92 commit f186d3e

File tree

6 files changed

+586
-3
lines changed

6 files changed

+586
-3
lines changed

aws-proxy/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ If you wish to access the deprecated instructions, they can be found [here](http
126126

127127
## Change Log
128128

129+
* `0.2.3`: Enhance proxy support and tests for several services (API Gateway v1/v2, CloudWatch, AppSync, Kinesis, KMS, SNS, Cognito-IDP)
129130
* `0.2.2`: Refactor UI to use WebAppExtension pattern
130131
* `0.2.1`: Restructure project to use pyproject.toml
131132
* `0.2.0`: Rename extension from `localstack-extension-aws-replicator` to `localstack-extension-aws-proxy`

aws-proxy/aws_proxy/client/auth_proxy.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,10 @@ def proxy_request(self, request: Request, data: bytes) -> Response:
9090
if not parsed:
9191
return requests_response("", status_code=400)
9292
region_name, service_name = parsed
93-
# Map AWS signing names to boto3 client names
94-
service_name = SERVICE_NAME_MAPPING.get(service_name, service_name)
93+
94+
# Map service names based on request context
95+
service_name = self._get_service_name(service_name, request.path)
96+
9597
query_string = to_str(request.query_string or "")
9698

9799
LOG.debug(
@@ -349,6 +351,18 @@ def _extract_region_and_service(self, headers) -> Optional[Tuple[str, str]]:
349351
return
350352
return parts[2], parts[3]
351353

354+
def _get_service_name(self, service_name: str, path: str) -> str:
355+
"""Map AWS signing service names to boto3 client names based on request context."""
356+
# Map AWS signing names to boto3 client names
357+
service_name = SERVICE_NAME_MAPPING.get(service_name, service_name)
358+
# API Gateway v2 uses 'apigateway' as signing name but needs 'apigatewayv2' client
359+
if service_name == "apigateway" and path.startswith("/v2/"):
360+
return "apigatewayv2"
361+
# CloudWatch uses 'monitoring' as signing name
362+
if service_name == "monitoring":
363+
return "cloudwatch"
364+
return service_name
365+
352366
@cache
353367
def _query_account_id_from_aws(self) -> str:
354368
session = boto3.Session()

aws-proxy/aws_proxy/server/aws_request_forwarder.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,10 @@ def _is_read_request(self, context: RequestContext) -> bool:
283283
"BatchGetServiceLevelIndicatorReport",
284284
}:
285285
return True
286+
if context.service.service_name == "apigatewayv2" and operation_name in {
287+
"ExportApi",
288+
}:
289+
return True
286290
# TODO: add more rules
287291
return False
288292

aws-proxy/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
66
[project]
77
name = "localstack-extension-aws-proxy"
88
readme = "README.md"
9-
version = "0.2.2"
9+
version = "0.2.3"
1010
description = "LocalStack extension that proxies AWS resources into your LocalStack instance"
1111
authors = [
1212
{ name = "LocalStack Team", email = "info@localstack.cloud"}
Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
# Note: This file has been (partially or fully) generated by an AI agent.
2+
import boto3
3+
import pytest
4+
from botocore.exceptions import ClientError
5+
from localstack.aws.connect import connect_to
6+
from localstack.utils.strings import short_uid
7+
8+
from aws_proxy.shared.models import ProxyConfig
9+
10+
11+
def test_apigateway_rest_api_requests(start_aws_proxy, cleanups):
12+
api_name_aws = f"test-api-aws-{short_uid()}"
13+
14+
# start proxy - forwarding all API Gateway requests
15+
config = ProxyConfig(services={"apigateway": {"resources": ".*"}})
16+
start_aws_proxy(config)
17+
18+
# create clients
19+
region_name = "us-east-1"
20+
apigw_client = connect_to(region_name=region_name).apigateway
21+
apigw_client_aws = boto3.client("apigateway", region_name=region_name)
22+
23+
# create REST API in AWS
24+
create_response_aws = apigw_client_aws.create_rest_api(
25+
name=api_name_aws, description="Test API for proxy testing"
26+
)
27+
api_id_aws = create_response_aws["id"]
28+
cleanups.append(lambda: apigw_client_aws.delete_rest_api(restApiId=api_id_aws))
29+
30+
# assert that local call for this API is proxied
31+
get_api_local = apigw_client.get_rest_api(restApiId=api_id_aws)
32+
get_api_aws = apigw_client_aws.get_rest_api(restApiId=api_id_aws)
33+
assert get_api_local["name"] == get_api_aws["name"] == api_name_aws
34+
assert get_api_local["id"] == get_api_aws["id"] == api_id_aws
35+
36+
# negative test: verify that requesting a non-existent API fails
37+
with pytest.raises(ClientError) as ctx:
38+
apigw_client_aws.get_rest_api(restApiId="nonexistent123")
39+
assert ctx.value.response["Error"]["Code"] == "NotFoundException"
40+
41+
# list APIs from AWS should include the created API
42+
apis_aws = apigw_client_aws.get_rest_apis()
43+
aws_api_ids = [api["id"] for api in apis_aws.get("items", [])]
44+
assert api_id_aws in aws_api_ids
45+
46+
# update API description via LocalStack client (should proxy to AWS)
47+
updated_description = "Updated description via proxy"
48+
apigw_client.update_rest_api(
49+
restApiId=api_id_aws,
50+
patchOperations=[
51+
{"op": "replace", "path": "/description", "value": updated_description}
52+
],
53+
)
54+
55+
# verify update is reflected in AWS
56+
get_api_aws = apigw_client_aws.get_rest_api(restApiId=api_id_aws)
57+
assert get_api_aws["description"] == updated_description
58+
59+
60+
def test_apigateway_resources_and_methods(start_aws_proxy, cleanups):
61+
api_name_aws = f"test-api-resources-{short_uid()}"
62+
63+
# start proxy - forwarding all API Gateway requests
64+
config = ProxyConfig(services={"apigateway": {"resources": ".*"}})
65+
start_aws_proxy(config)
66+
67+
# create clients
68+
region_name = "us-east-1"
69+
apigw_client = connect_to(region_name=region_name).apigateway
70+
apigw_client_aws = boto3.client("apigateway", region_name=region_name)
71+
72+
# create REST API in AWS
73+
create_response_aws = apigw_client_aws.create_rest_api(name=api_name_aws)
74+
api_id_aws = create_response_aws["id"]
75+
cleanups.append(lambda: apigw_client_aws.delete_rest_api(restApiId=api_id_aws))
76+
77+
# get root resource from AWS
78+
resources_response = apigw_client_aws.get_resources(restApiId=api_id_aws)
79+
root_resource_id = resources_response["items"][0]["id"]
80+
81+
# create a new resource via AWS client
82+
resource_response = apigw_client_aws.create_resource(
83+
restApiId=api_id_aws, parentId=root_resource_id, pathPart="users"
84+
)
85+
resource_id = resource_response["id"]
86+
87+
# create a method via AWS client
88+
apigw_client_aws.put_method(
89+
restApiId=api_id_aws,
90+
resourceId=resource_id,
91+
httpMethod="GET",
92+
authorizationType="NONE",
93+
)
94+
95+
# verify method exists via LocalStack client (should proxy to AWS)
96+
method_local = apigw_client.get_method(
97+
restApiId=api_id_aws, resourceId=resource_id, httpMethod="GET"
98+
)
99+
assert method_local["httpMethod"] == "GET"
100+
assert method_local["authorizationType"] == "NONE"
101+
102+
# create method response via AWS client
103+
apigw_client_aws.put_method_response(
104+
restApiId=api_id_aws,
105+
resourceId=resource_id,
106+
httpMethod="GET",
107+
statusCode="200",
108+
)
109+
110+
# verify method response via LocalStack (proxied)
111+
method_response_local = apigw_client.get_method_response(
112+
restApiId=api_id_aws, resourceId=resource_id, httpMethod="GET", statusCode="200"
113+
)
114+
assert method_response_local["statusCode"] == "200"
115+
116+
# create integration via AWS client
117+
apigw_client_aws.put_integration(
118+
restApiId=api_id_aws,
119+
resourceId=resource_id,
120+
httpMethod="GET",
121+
type="MOCK",
122+
requestTemplates={"application/json": '{"statusCode": 200}'},
123+
)
124+
125+
# verify integration via LocalStack (proxied)
126+
integration_local = apigw_client.get_integration(
127+
restApiId=api_id_aws, resourceId=resource_id, httpMethod="GET"
128+
)
129+
assert integration_local["type"] == "MOCK"
130+
131+
# delete method via AWS client
132+
apigw_client_aws.delete_method(
133+
restApiId=api_id_aws, resourceId=resource_id, httpMethod="GET"
134+
)
135+
136+
# verify method is deleted via LocalStack (proxied)
137+
with pytest.raises(ClientError) as ctx:
138+
apigw_client.get_method(
139+
restApiId=api_id_aws, resourceId=resource_id, httpMethod="GET"
140+
)
141+
assert ctx.value.response["Error"]["Code"] in ["NotFoundException", "404"]
142+
143+
144+
def test_apigateway_deployments(start_aws_proxy, cleanups):
145+
api_name_aws = f"test-api-deploy-{short_uid()}"
146+
147+
# start proxy - forwarding all API Gateway requests
148+
config = ProxyConfig(services={"apigateway": {"resources": ".*"}})
149+
start_aws_proxy(config)
150+
151+
# create clients
152+
region_name = "us-east-1"
153+
apigw_client = connect_to(region_name=region_name).apigateway
154+
apigw_client_aws = boto3.client("apigateway", region_name=region_name)
155+
156+
# create REST API in AWS
157+
create_response_aws = apigw_client_aws.create_rest_api(name=api_name_aws)
158+
api_id_aws = create_response_aws["id"]
159+
cleanups.append(lambda: apigw_client_aws.delete_rest_api(restApiId=api_id_aws))
160+
161+
# get root resource and create a simple method
162+
resources_response = apigw_client_aws.get_resources(restApiId=api_id_aws)
163+
root_resource_id = resources_response["items"][0]["id"]
164+
165+
apigw_client_aws.put_method(
166+
restApiId=api_id_aws,
167+
resourceId=root_resource_id,
168+
httpMethod="GET",
169+
authorizationType="NONE",
170+
)
171+
apigw_client_aws.put_integration(
172+
restApiId=api_id_aws,
173+
resourceId=root_resource_id,
174+
httpMethod="GET",
175+
type="MOCK",
176+
)
177+
178+
# create deployment via LocalStack client (should proxy to AWS)
179+
stage_name = "test"
180+
deployment_response = apigw_client.create_deployment(
181+
restApiId=api_id_aws, stageName=stage_name, description="Test deployment"
182+
)
183+
deployment_id = deployment_response["id"]
184+
185+
# verify deployment exists in AWS
186+
deployment_aws = apigw_client_aws.get_deployment(
187+
restApiId=api_id_aws, deploymentId=deployment_id
188+
)
189+
assert deployment_aws["id"] == deployment_id
190+
assert deployment_aws["description"] == "Test deployment"
191+
192+
# get stage via LocalStack client
193+
stage_local = apigw_client.get_stage(restApiId=api_id_aws, stageName=stage_name)
194+
stage_aws = apigw_client_aws.get_stage(restApiId=api_id_aws, stageName=stage_name)
195+
assert stage_local["stageName"] == stage_aws["stageName"] == stage_name
196+
assert stage_local["deploymentId"] == stage_aws["deploymentId"] == deployment_id
197+
198+
# list deployments via AWS client (verify deployment exists)
199+
deployments_aws = apigw_client_aws.get_deployments(restApiId=api_id_aws)
200+
aws_deployment_ids = [d["id"] for d in deployments_aws.get("items", [])]
201+
assert deployment_id in aws_deployment_ids
202+
203+
204+
def test_apigateway_read_only_mode(start_aws_proxy, cleanups):
205+
api_name_aws = f"test-api-readonly-{short_uid()}"
206+
207+
# create REST API in AWS first (before starting proxy)
208+
region_name = "us-east-1"
209+
apigw_client_aws = boto3.client("apigateway", region_name=region_name)
210+
create_response_aws = apigw_client_aws.create_rest_api(name=api_name_aws)
211+
api_id_aws = create_response_aws["id"]
212+
cleanups.append(lambda: apigw_client_aws.delete_rest_api(restApiId=api_id_aws))
213+
214+
# start proxy in read-only mode
215+
config = ProxyConfig(
216+
services={"apigateway": {"resources": ".*", "read_only": True}}
217+
)
218+
start_aws_proxy(config)
219+
220+
# create LocalStack client
221+
apigw_client = connect_to(region_name=region_name).apigateway
222+
223+
# read operations should work (proxied to AWS)
224+
get_api_local = apigw_client.get_rest_api(restApiId=api_id_aws)
225+
assert get_api_local["name"] == api_name_aws
226+
assert get_api_local["id"] == api_id_aws
227+
228+
# verify the API can also be read directly from AWS
229+
get_api_aws = apigw_client_aws.get_rest_api(restApiId=api_id_aws)
230+
assert get_api_local["name"] == get_api_aws["name"]
231+
232+
# write operations should fail (not allowed in read-only mode)
233+
# In read-only mode, the API exists in AWS but LocalStack should not
234+
# allow write operations to be proxied
235+
original_description = get_api_aws.get("description", "")
236+
237+
# Attempt write operation - should be blocked in read-only mode
238+
with pytest.raises(Exception):
239+
apigw_client.update_rest_api(
240+
restApiId=api_id_aws,
241+
patchOperations=[
242+
{
243+
"op": "replace",
244+
"path": "/description",
245+
"value": "Should not reach AWS",
246+
}
247+
],
248+
)
249+
250+
# Verify the API description was not changed in AWS
251+
get_api_aws_after = apigw_client_aws.get_rest_api(restApiId=api_id_aws)
252+
assert get_api_aws_after.get("description", "") == original_description
253+
254+
255+
def test_apigateway_selective_resource_matching(start_aws_proxy, cleanups):
256+
api_name_aws = f"test-api-selective-{short_uid()}"
257+
258+
# start proxy - forwarding all API Gateway requests
259+
config = ProxyConfig(services={"apigateway": {"resources": ".*"}})
260+
start_aws_proxy(config)
261+
262+
# create clients
263+
region_name = "us-east-1"
264+
apigw_client = connect_to(region_name=region_name).apigateway
265+
apigw_client_aws = boto3.client("apigateway", region_name=region_name)
266+
267+
# create API in AWS
268+
create_response_aws = apigw_client_aws.create_rest_api(name=api_name_aws)
269+
api_id_aws = create_response_aws["id"]
270+
cleanups.append(lambda: apigw_client_aws.delete_rest_api(restApiId=api_id_aws))
271+
272+
# accessing the API via LocalStack should be proxied
273+
get_api_local = apigw_client.get_rest_api(restApiId=api_id_aws)
274+
assert get_api_local["name"] == api_name_aws
275+
276+
# verify the API details match between LocalStack and AWS
277+
get_api_aws = apigw_client_aws.get_rest_api(restApiId=api_id_aws)
278+
assert get_api_local["id"] == get_api_aws["id"]
279+
assert get_api_local["name"] == get_api_aws["name"]
280+
281+
# negative test: verify that requesting a non-existent API ID fails
282+
with pytest.raises(ClientError) as ctx:
283+
apigw_client_aws.get_rest_api(restApiId="nonexistent123456")
284+
assert ctx.value.response["Error"]["Code"] == "NotFoundException"
285+
286+
# verify LocalStack also returns error for non-existent API
287+
with pytest.raises(ClientError) as ctx:
288+
apigw_client.get_rest_api(restApiId="nonexistent123456")
289+
assert ctx.value.response["Error"]["Code"] in ["NotFoundException", "404"]

0 commit comments

Comments
 (0)