Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/coverage/aws/apigateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ AWS's `apigateway` service · portable interface `driver.APIGateway` · [AWS ind
| `GetRestAPIs` | |
| `GetStage` | |
| `GetStages` | |
| `InvokeRoute` | InvokeRoute resolves req.HTTPMethod+req.Path against the deployed stage's |
| `InvokeRoute` | InvokeRoute routes req through the tree its stage's deployment captured. |
| `PutIntegration` | |
| `PutMethod` | |
| `UpdateDeployment` | UpdateDeployment applies a patchOperations document to a deployment |
Expand Down
2 changes: 1 addition & 1 deletion docs/coverage/coverage.json
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@
},
{
"name": "InvokeRoute",
"doc": "InvokeRoute resolves req.HTTPMethod+req.Path against the deployed stage's"
"doc": "InvokeRoute routes req through the tree its stage's deployment captured."
},
{
"name": "PutIntegration"
Expand Down
5 changes: 5 additions & 0 deletions providers/aws/apigateway/apigateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,15 @@ type LambdaInvoker interface {
// apiData is one REST API plus its full tree, guarded by its own lock. Every
// resource/method/integration/deployment/stage lives here so a single lock
// makes each control-plane op atomic and never exposes a half-written tree.
// trees holds, per deployment id, the resource tree captured when that
// deployment was created. A stage serves its deployment's tree, never the live
// resources, so edits stay invisible until the API is redeployed.
type apiData struct {
mu sync.RWMutex
api driver.RestAPI
resources map[string]*driver.Resource
deployments map[string]*driver.Deployment
trees map[string]map[string]*driver.Resource
stages map[string]*driver.Stage
}

Expand Down Expand Up @@ -135,6 +139,7 @@ func (m *Mock) CreateRestAPI(_ context.Context, in *driver.CreateRestAPIInput) (
api: api,
resources: map[string]*driver.Resource{rootID: root},
deployments: map[string]*driver.Deployment{},
trees: map[string]map[string]*driver.Resource{},
stages: map[string]*driver.Stage{},
})

Expand Down
4 changes: 2 additions & 2 deletions providers/aws/apigateway/apigateway_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -290,13 +290,13 @@ func TestInvokeRouteLiteralBeatsGreedy(t *testing.T) {
health, _ := m.CreateResource(ctx(), api.ID, api.RootResourceID, "health")
_, _ = m.PutMethod(ctx(), api.ID, health.ID, "GET", driver.PutMethodInput{})
_, _ = m.PutIntegration(ctx(), api.ID, health.ID, "GET", driver.PutIntegrationInput{
Type: driver.IntegrationAWSProxy, URI: lambdaURI,
Type: driver.IntegrationAWSProxy, IntegrationHTTPMethod: "POST", URI: lambdaURI,
})

proxy, _ := m.CreateResource(ctx(), api.ID, api.RootResourceID, "{proxy+}")
_, _ = m.PutMethod(ctx(), api.ID, proxy.ID, "ANY", driver.PutMethodInput{})
_, _ = m.PutIntegration(ctx(), api.ID, proxy.ID, "ANY", driver.PutIntegrationInput{
Type: driver.IntegrationAWSProxy, URI: lambdaURI,
Type: driver.IntegrationAWSProxy, IntegrationHTTPMethod: "POST", URI: lambdaURI,
})

_, _ = m.CreateDeployment(ctx(), api.ID, driver.CreateDeploymentInput{StageName: "prod"})
Expand Down
14 changes: 10 additions & 4 deletions providers/aws/apigateway/dataplane.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type resolvedRoute struct {
resourcePath string
integration driver.Integration
pathParameters map[string]string
stageVariables map[string]string
apiID string
}

Expand Down Expand Up @@ -71,7 +72,7 @@ func (m *Mock) serveRoute(ctx context.Context, req *driver.ProxyRequest) (*drive
return jsonResponse(statusBadGway, `{"message": "Internal server error"}`), noIntegration
}

target := extractLambdaTarget(route.integration.URI)
target := extractLambdaTarget(substituteStageVariables(route.integration.URI, route.stageVariables, ""))

invokeStart := m.opts.Clock.Now()
out, fnErr, invErr := m.lambda.InvokeSync(ctx, target, event)
Expand All @@ -84,7 +85,9 @@ func (m *Mock) serveRoute(ctx context.Context, req *driver.ProxyRequest) (*drive
return mapLambdaResponse(out), integration
}

// resolve locks the API, resolves the stage and route, and returns a snapshot.
// resolve locks the API, resolves the stage and the route in the tree the
// stage's deployment captured, and returns a snapshot. Live edits made since
// that deployment are not visible here.
func (m *Mock) resolve(req *driver.ProxyRequest) (resolvedRoute, bool) {
ad, err := m.getAPI(req.RestAPIID)
if err != nil {
Expand All @@ -94,11 +97,12 @@ func (m *Mock) resolve(req *driver.ProxyRequest) (resolvedRoute, bool) {
ad.mu.RLock()
defer ad.mu.RUnlock()

if _, ok := ad.stages[req.StageName]; !ok {
st, ok := ad.stages[req.StageName]
if !ok {
return resolvedRoute{}, false
}

match, ok := matchRoute(ad.resources, req.HTTPMethod, req.Path)
match, ok := matchRoute(ad.trees[st.DeploymentID], req.HTTPMethod, req.Path)
if !ok || match.method.Integration == nil {
return resolvedRoute{}, false
}
Expand All @@ -108,6 +112,7 @@ func (m *Mock) resolve(req *driver.ProxyRequest) (resolvedRoute, bool) {
resourcePath: match.resource.Path,
integration: *match.method.Integration,
pathParameters: match.pathParameters,
stageVariables: copyStrMap(st.Variables),
apiID: req.RestAPIID,
}, true
}
Expand Down Expand Up @@ -192,6 +197,7 @@ func buildProxyEvent(req *driver.ProxyRequest, route *resolvedRoute, accountID s
QueryStringParameters: emptyToNil(req.Query),
MultiValueQueryStringParameters: req.MultiValueQuery,
PathParameters: emptyToNil(route.pathParameters),
StageVariables: emptyToNil(route.stageVariables),
Body: req.Body,
IsBase64Encoded: req.IsBase64Encoded,
RequestContext: proxyRequestContext{
Expand Down
98 changes: 85 additions & 13 deletions providers/aws/apigateway/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import (
"github.com/stackshy/cloudemu/v2/services/apigateway/driver"
)

// CreateDeployment snapshots the API and, when a StageName is supplied,
// creates (or re-points) that stage to the new deployment. That's the one-shot
// deploy the real CreateDeployment performs.
// CreateDeployment captures the API's current resource tree and, when a
// StageName is supplied, creates that stage or re-points it at the new
// deployment. That's the one-shot deploy the real CreateDeployment performs.
// An API with no methods, or with a method that has no integration, cannot be
// deployed.
func (m *Mock) CreateDeployment(
_ context.Context, restAPIID string, in driver.CreateDeploymentInput,
) (*driver.Deployment, error) {
Expand All @@ -22,23 +24,91 @@ func (m *Mock) CreateDeployment(
ad.mu.Lock()
defer ad.mu.Unlock()

if err := validateDeployable(ad.resources); err != nil {
return nil, err
}

if in.StageName != "" {
if err := validateStageName(in.StageName); err != nil {
return nil, err
}
}

dep := &driver.Deployment{
ID: genID(), RestAPIID: restAPIID, Description: in.Description, CreatedDate: m.now(),
}
ad.deployments[dep.ID] = dep
ad.trees[dep.ID] = copyTree(ad.resources)

if in.StageName != "" {
ad.stages[in.StageName] = &driver.Stage{
StageName: in.StageName, RestAPIID: restAPIID,
DeploymentID: dep.ID, CreatedDate: m.now(),
}
m.deployToStage(ad, dep.ID, &in)
}

out := *dep

return &out, nil
}

// deployToStage points the named stage at deploymentID, creating the stage
// with the deployment's stage description when it does not exist yet. An
// existing stage keeps its settings, and the input variables are merged in.
func (m *Mock) deployToStage(ad *apiData, deploymentID string, in *driver.CreateDeploymentInput) {
st, ok := ad.stages[in.StageName]
if !ok {
st = &driver.Stage{
StageName: in.StageName, RestAPIID: ad.api.ID,
Description: in.StageDescription, CreatedDate: m.now(),
}
ad.stages[in.StageName] = st
}

st.DeploymentID = deploymentID

if len(in.Variables) > 0 && st.Variables == nil {
st.Variables = make(map[string]string, len(in.Variables))
}

for k, v := range in.Variables {
st.Variables[k] = v
}
}

// copyTree deep-copies a resource tree so a deployment's capture never shares
// a pointer with the live resources.
func copyTree(resources map[string]*driver.Resource) map[string]*driver.Resource {
out := make(map[string]*driver.Resource, len(resources))

for id, r := range resources {
cp := copyResource(r)
out[id] = &cp
}

return out
}

// apiSummary renders a captured tree as the path -> method -> summary map
// GetDeployment returns under embed=apisummary.
func apiSummary(tree map[string]*driver.Resource) map[string]map[string]driver.MethodSnapshot {
out := map[string]map[string]driver.MethodSnapshot{}

for _, r := range tree {
if len(r.Methods) == 0 {
continue
}

methods := make(map[string]driver.MethodSnapshot, len(r.Methods))
for name, mth := range r.Methods {
methods[name] = driver.MethodSnapshot{
AuthorizationType: mth.AuthorizationType, APIKeyRequired: mth.APIKeyRequired,
}
}

out[r.Path] = methods
}

return out
}

// GetDeployments lists every deployment of a REST API.
func (m *Mock) GetDeployments(_ context.Context, restAPIID string) ([]driver.Deployment, error) {
ad, err := m.getAPI(restAPIID)
Expand Down Expand Up @@ -79,10 +149,11 @@ func (m *Mock) GetDeployment(_ context.Context, restAPIID, deploymentID string)

d, ok := ad.deployments[deploymentID]
if !ok {
return nil, cerrors.Newf(cerrors.NotFound, "Invalid deployment identifier specified %s", deploymentID)
return nil, cerrors.New(cerrors.NotFound, msgDeploymentNotFound)
}

out := *d
out.APISummary = apiSummary(ad.trees[deploymentID])

return &out, nil
}
Expand All @@ -101,7 +172,7 @@ func (m *Mock) DeleteDeployment(_ context.Context, restAPIID, deploymentID strin
defer ad.mu.Unlock()

if _, ok := ad.deployments[deploymentID]; !ok {
return cerrors.Newf(cerrors.NotFound, "Invalid deployment identifier specified %s", deploymentID)
return cerrors.New(cerrors.NotFound, msgDeploymentNotFound)
}

for _, st := range ad.stages {
Expand All @@ -112,14 +183,15 @@ func (m *Mock) DeleteDeployment(_ context.Context, restAPIID, deploymentID strin
}

delete(ad.deployments, deploymentID)
delete(ad.trees, deploymentID)

return nil
}

// CreateStage points a named stage at an existing deployment.
func (m *Mock) CreateStage(_ context.Context, restAPIID string, in driver.CreateStageInput) (*driver.Stage, error) {
if in.StageName == "" {
return nil, cerrors.New(cerrors.InvalidArgument, "stageName is required")
if err := validateStageName(in.StageName); err != nil {
return nil, err
}

if in.DeploymentID == "" {
Expand All @@ -135,11 +207,11 @@ func (m *Mock) CreateStage(_ context.Context, restAPIID string, in driver.Create
defer ad.mu.Unlock()

if _, ok := ad.deployments[in.DeploymentID]; !ok {
return nil, cerrors.Newf(cerrors.NotFound, "Invalid deployment identifier specified %s", in.DeploymentID)
return nil, cerrors.New(cerrors.NotFound, msgDeploymentNotFound)
}

if _, exists := ad.stages[in.StageName]; exists {
return nil, cerrors.Newf(cerrors.AlreadyExists, "Stage already exists: %s", in.StageName)
return nil, cerrors.New(cerrors.AlreadyExists, msgStageExists)
}

st := &driver.Stage{
Expand Down
Loading
Loading