diff --git a/docs/coverage/aws/apigateway.md b/docs/coverage/aws/apigateway.md index a499c5c6c..2f4cdeccf 100644 --- a/docs/coverage/aws/apigateway.md +++ b/docs/coverage/aws/apigateway.md @@ -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 | diff --git a/docs/coverage/coverage.json b/docs/coverage/coverage.json index c89c15b00..6459156fb 100644 --- a/docs/coverage/coverage.json +++ b/docs/coverage/coverage.json @@ -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" diff --git a/providers/aws/apigateway/apigateway.go b/providers/aws/apigateway/apigateway.go index a3c601dbd..4bb1e0767 100644 --- a/providers/aws/apigateway/apigateway.go +++ b/providers/aws/apigateway/apigateway.go @@ -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 } @@ -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{}, }) diff --git a/providers/aws/apigateway/apigateway_test.go b/providers/aws/apigateway/apigateway_test.go index 3381a4e3b..dc060584d 100644 --- a/providers/aws/apigateway/apigateway_test.go +++ b/providers/aws/apigateway/apigateway_test.go @@ -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"}) diff --git a/providers/aws/apigateway/dataplane.go b/providers/aws/apigateway/dataplane.go index 002744b36..09c8f27d8 100644 --- a/providers/aws/apigateway/dataplane.go +++ b/providers/aws/apigateway/dataplane.go @@ -25,6 +25,7 @@ type resolvedRoute struct { resourcePath string integration driver.Integration pathParameters map[string]string + stageVariables map[string]string apiID string } @@ -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) @@ -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 { @@ -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 } @@ -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 } @@ -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{ diff --git a/providers/aws/apigateway/deploy.go b/providers/aws/apigateway/deploy.go index a40349689..6f96136cf 100644 --- a/providers/aws/apigateway/deploy.go +++ b/providers/aws/apigateway/deploy.go @@ -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) { @@ -22,16 +24,24 @@ 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 @@ -39,6 +49,66 @@ func (m *Mock) CreateDeployment( 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) @@ -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 } @@ -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 { @@ -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 == "" { @@ -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{ diff --git a/providers/aws/apigateway/deployment_snapshot_test.go b/providers/aws/apigateway/deployment_snapshot_test.go new file mode 100644 index 000000000..ca4b355af --- /dev/null +++ b/providers/aws/apigateway/deployment_snapshot_test.go @@ -0,0 +1,420 @@ +package apigateway_test + +import ( + "encoding/json" + "testing" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/providers/aws/apigateway" + "github.com/stackshy/cloudemu/v2/services/apigateway/driver" +) + +const helloTarget = "arn:aws:lambda:us-east-1:000000000000:function:hello" + +// invokeStatus routes one GET through the given stage and returns the status. +func invokeStatus(t *testing.T, m *apigateway.Mock, apiID, stage, path string) int { + t.Helper() + + resp, err := m.InvokeRoute(ctx(), &driver.ProxyRequest{ + RestAPIID: apiID, StageName: stage, HTTPMethod: "GET", Path: path, + }) + if err != nil { + t.Fatalf("InvokeRoute: %v", err) + } + + return resp.StatusCode +} + +// addLambdaMethod creates pathPart under the root with a GET AWS_PROXY method. +func addLambdaMethod(t *testing.T, m *apigateway.Mock, apiID, rootID, pathPart, uri string) string { + t.Helper() + + res, err := m.CreateResource(ctx(), apiID, rootID, pathPart) + if err != nil { + t.Fatalf("CreateResource(%s): %v", pathPart, err) + } + + if _, err := m.PutMethod(ctx(), apiID, res.ID, "GET", driver.PutMethodInput{}); err != nil { + t.Fatalf("PutMethod: %v", err) + } + + if _, err := m.PutIntegration(ctx(), apiID, res.ID, "GET", driver.PutIntegrationInput{ + Type: driver.IntegrationAWSProxy, IntegrationHTTPMethod: "POST", URI: uri, + }); err != nil { + t.Fatalf("PutIntegration: %v", err) + } + + return res.ID +} + +func assertMessage(t *testing.T, err error, isCode func(error) bool, want string) { + t.Helper() + + if !isCode(err) { + t.Fatalf("got %v, want a %q error of the expected code", err, want) + } + + if got := errors.Message(err); got != want { + t.Fatalf("message = %q, want %q", got, want) + } +} + +// TestDeploymentSnapshotGatesLiveEdits proves a stage serves the tree captured +// at CreateDeployment time: edits to the live API are invisible until the API +// is redeployed. +func TestDeploymentSnapshotGatesLiveEdits(t *testing.T) { + m := newMock(t) + inv := &fakeInvoker{output: []byte(`{"statusCode":200,"body":"ok"}`)} + m.SetLambdaInvoker(inv) + + apiID, rootID, helloID := deployProxyAPI(t, m, "hello", "GET", lambdaURI) + + // A new resource added after the deploy is not live yet. + addLambdaMethod(t, m, apiID, rootID, "fresh", lambdaURI) + + if got := invokeStatus(t, m, apiID, "prod", "/fresh"); got != 403 { + t.Fatalf("undeployed /fresh = %d, want 403", got) + } + + // Re-pointing the live integration does not change the deployed target. + const otherURI = "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/" + + "arn:aws:lambda:us-east-1:000000000000:function:other/invocations" + + if _, err := m.UpdateIntegration(ctx(), apiID, helloID, "GET", []driver.PatchOperation{ + {Op: "replace", Path: "/uri", Value: otherURI}, + }); err != nil { + t.Fatalf("UpdateIntegration: %v", err) + } + + if got := invokeStatus(t, m, apiID, "prod", "/hello"); got != 200 || inv.lastTarget != helloTarget { + t.Fatalf("deployed /hello = %d via %q, want 200 via the deployed target", got, inv.lastTarget) + } + + // Deleting the live method leaves the deployed one serving. + if err := m.DeleteMethod(ctx(), apiID, helloID, "GET"); err != nil { + t.Fatalf("DeleteMethod: %v", err) + } + + if got := invokeStatus(t, m, apiID, "prod", "/hello"); got != 200 { + t.Fatalf("deployed /hello after live delete = %d, want 200", got) + } + + // Redeploying publishes the live tree. + if _, err := m.CreateDeployment(ctx(), apiID, driver.CreateDeploymentInput{StageName: "prod"}); err != nil { + t.Fatalf("redeploy: %v", err) + } + + if got := invokeStatus(t, m, apiID, "prod", "/fresh"); got != 200 { + t.Fatalf("redeployed /fresh = %d, want 200", got) + } + + if got := invokeStatus(t, m, apiID, "prod", "/hello"); got != 403 { + t.Fatalf("redeployed /hello (method deleted) = %d, want 403", got) + } +} + +// TestUpdateStageRepointsDeploymentSnapshot proves UpdateStage /deploymentId +// switches the stage back to an older deployment's tree. +func TestUpdateStageRepointsDeploymentSnapshot(t *testing.T) { + m := newMock(t) + m.SetLambdaInvoker(&fakeInvoker{output: []byte(`{"statusCode":200,"body":"ok"}`)}) + + apiID, rootID, _ := deployProxyAPI(t, m, "hello", "GET", lambdaURI) + + first, err := m.GetStage(ctx(), apiID, "prod") + if err != nil { + t.Fatalf("GetStage: %v", err) + } + + addLambdaMethod(t, m, apiID, rootID, "fresh", lambdaURI) + + if _, err := m.CreateDeployment(ctx(), apiID, driver.CreateDeploymentInput{StageName: "prod"}); err != nil { + t.Fatalf("redeploy: %v", err) + } + + if got := invokeStatus(t, m, apiID, "prod", "/fresh"); got != 200 { + t.Fatalf("/fresh on second deployment = %d, want 200", got) + } + + if _, err := m.UpdateStage(ctx(), apiID, "prod", []driver.PatchOperation{ + {Op: "replace", Path: "/deploymentId", Value: first.DeploymentID}, + }); err != nil { + t.Fatalf("UpdateStage: %v", err) + } + + if got := invokeStatus(t, m, apiID, "prod", "/fresh"); got != 403 { + t.Fatalf("/fresh after rollback = %d, want 403", got) + } +} + +// TestStageVariablesResolvedOnInvoke proves ${stageVariables.x} in a Lambda +// integration URI resolves per stage, the proxy event carries stageVariables, +// and a variable change applies without a redeploy. +func TestStageVariablesResolvedOnInvoke(t *testing.T) { + m := newMock(t) + inv := &fakeInvoker{output: []byte(`{"statusCode":200,"body":"ok"}`)} + m.SetLambdaInvoker(inv) + + const varURI = "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/" + + "arn:aws:lambda:us-east-1:000000000000:function:${stageVariables.fn}/invocations" + + api, _ := m.CreateRestAPI(ctx(), &driver.CreateRestAPIInput{Name: "vars"}) + addLambdaMethod(t, m, api.ID, api.RootResourceID, "hello", varURI) + + dep, err := m.CreateDeployment(ctx(), api.ID, driver.CreateDeploymentInput{ + StageName: "prod", Variables: map[string]string{"fn": "hello-prod"}, + }) + if err != nil { + t.Fatalf("CreateDeployment: %v", err) + } + + if _, err := m.CreateStage(ctx(), api.ID, driver.CreateStageInput{ + StageName: "dev", DeploymentID: dep.ID, Variables: map[string]string{"fn": "hello-dev"}, + }); err != nil { + t.Fatalf("CreateStage: %v", err) + } + + invokeStatus(t, m, api.ID, "prod", "/hello") + + if inv.lastTarget != "arn:aws:lambda:us-east-1:000000000000:function:hello-prod" { + t.Fatalf("prod target = %q", inv.lastTarget) + } + + var event struct { + StageVariables map[string]string `json:"stageVariables"` + } + _ = json.Unmarshal(inv.lastPayload, &event) + + if event.StageVariables["fn"] != "hello-prod" { + t.Fatalf("event stageVariables = %v", event.StageVariables) + } + + invokeStatus(t, m, api.ID, "dev", "/hello") + + if inv.lastTarget != "arn:aws:lambda:us-east-1:000000000000:function:hello-dev" { + t.Fatalf("dev target = %q", inv.lastTarget) + } + + if _, err := m.UpdateStage(ctx(), api.ID, "dev", []driver.PatchOperation{ + {Op: "replace", Path: "/variables/fn", Value: "hello-v2"}, + }); err != nil { + t.Fatalf("UpdateStage: %v", err) + } + + invokeStatus(t, m, api.ID, "dev", "/hello") + + if inv.lastTarget != "arn:aws:lambda:us-east-1:000000000000:function:hello-v2" { + t.Fatalf("dev target after variable update = %q", inv.lastTarget) + } +} + +// TestCreateDeploymentRepointsExistingStage proves redeploying to an existing +// stage keeps its variables and description and merges new variables. +func TestCreateDeploymentRepointsExistingStage(t *testing.T) { + m := newMock(t) + api, _ := m.CreateRestAPI(ctx(), &driver.CreateRestAPIInput{Name: "x"}) + addLambdaMethod(t, m, api.ID, api.RootResourceID, "hello", lambdaURI) + + first, err := m.CreateDeployment(ctx(), api.ID, driver.CreateDeploymentInput{ + StageName: "prod", StageDescription: "production", Variables: map[string]string{"a": "1"}, + }) + if err != nil { + t.Fatalf("CreateDeployment: %v", err) + } + + second, err := m.CreateDeployment(ctx(), api.ID, driver.CreateDeploymentInput{ + StageName: "prod", Variables: map[string]string{"b": "2"}, + }) + if err != nil { + t.Fatalf("second CreateDeployment: %v", err) + } + + st, _ := m.GetStage(ctx(), api.ID, "prod") + if st.DeploymentID != second.ID || st.DeploymentID == first.ID { + t.Fatalf("stage deploymentId = %q, want %q", st.DeploymentID, second.ID) + } + + if st.Description != "production" || st.Variables["a"] != "1" || st.Variables["b"] != "2" { + t.Fatalf("stage lost its settings on redeploy: %+v", st) + } +} + +func TestCreateDeploymentValidation(t *testing.T) { + m := newMock(t) + api, _ := m.CreateRestAPI(ctx(), &driver.CreateRestAPIInput{Name: "x"}) + + _, err := m.CreateDeployment(ctx(), api.ID, driver.CreateDeploymentInput{StageName: "prod"}) + assertMessage(t, err, errors.IsInvalidArgument, "The REST API doesn't contain any methods") + + res, _ := m.CreateResource(ctx(), api.ID, api.RootResourceID, "pets") + if _, err := m.PutMethod(ctx(), api.ID, res.ID, "GET", driver.PutMethodInput{}); err != nil { + t.Fatalf("PutMethod: %v", err) + } + + _, err = m.CreateDeployment(ctx(), api.ID, driver.CreateDeploymentInput{StageName: "prod"}) + assertMessage(t, err, errors.IsInvalidArgument, "No integration defined for method") + + if stages, _ := m.GetStages(ctx(), api.ID); len(stages) != 0 { + t.Fatalf("a rejected deployment created stages: %+v", stages) + } + + if _, err := m.PutIntegration(ctx(), api.ID, res.ID, "GET", driver.PutIntegrationInput{Type: "MOCK"}); err != nil { + t.Fatalf("PutIntegration: %v", err) + } + + _, err = m.CreateDeployment(ctx(), api.ID, driver.CreateDeploymentInput{StageName: "bad.name"}) + assertMessage(t, err, errors.IsInvalidArgument, "Stage name only allows a-zA-Z0-9_-") + + if deps, _ := m.GetDeployments(ctx(), api.ID); len(deps) != 0 { + t.Fatalf("a rejected deployment was stored: %+v", deps) + } +} + +func TestPutMethodValidation(t *testing.T) { + m := newMock(t) + api, _ := m.CreateRestAPI(ctx(), &driver.CreateRestAPIInput{Name: "x"}) + res, _ := m.CreateResource(ctx(), api.ID, api.RootResourceID, "pets") + + _, err := m.PutMethod(ctx(), api.ID, res.ID, "FOO", driver.PutMethodInput{}) + assertMessage(t, err, errors.IsInvalidArgument, "Invalid HTTP method specified") + + _, err = m.PutMethod(ctx(), api.ID, "nope", "GET", driver.PutMethodInput{}) + assertMessage(t, err, errors.IsNotFound, "Invalid Resource identifier specified") + + for _, verb := range []string{"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "ANY"} { + if _, err := m.PutMethod(ctx(), api.ID, res.ID, verb, driver.PutMethodInput{}); err != nil { + t.Fatalf("PutMethod(%s): %v", verb, err) + } + } + + _, err = m.PutMethod(ctx(), api.ID, res.ID, "GET", driver.PutMethodInput{}) + assertMessage(t, err, errors.IsAlreadyExists, "Method already exists for this resource") +} + +func TestPutIntegrationValidation(t *testing.T) { + m := newMock(t) + api, _ := m.CreateRestAPI(ctx(), &driver.CreateRestAPIInput{Name: "x"}) + res, _ := m.CreateResource(ctx(), api.ID, api.RootResourceID, "pets") + + if _, err := m.PutMethod(ctx(), api.ID, res.ID, "GET", driver.PutMethodInput{}); err != nil { + t.Fatalf("PutMethod: %v", err) + } + + cases := []struct { + name string + in driver.PutIntegrationInput + want string + }{ + {"bad type", driver.PutIntegrationInput{Type: "FOO"}, + "1 validation error detected: Value 'FOO' at 'putIntegrationInput.type' failed to satisfy " + + "constraint: Member must satisfy enum value set: [HTTP, AWS, MOCK, HTTP_PROXY, AWS_PROXY]"}, + {"http no method", driver.PutIntegrationInput{Type: "HTTP", URI: "https://example.com"}, + "Enumeration value for HttpMethod must be non-empty"}, + {"http bad uri", driver.PutIntegrationInput{Type: "HTTP_PROXY", IntegrationHTTPMethod: "GET", URI: "not a url"}, + "Invalid HTTP endpoint specified for URI"}, + {"http no uri", driver.PutIntegrationInput{Type: "HTTP", IntegrationHTTPMethod: "GET"}, + "Invalid HTTP endpoint specified for URI"}, + {"aws bad arn", driver.PutIntegrationInput{Type: "AWS_PROXY", IntegrationHTTPMethod: "POST", URI: "hello"}, + "Invalid ARN specified in the request"}, + {"aws no path", driver.PutIntegrationInput{ + Type: "AWS", IntegrationHTTPMethod: "POST", URI: "arn:aws:apigateway:us-east-1:sqs:foo", + }, "AWS ARN for integration must contain path or action"}, + {"aws_proxy not lambda", driver.PutIntegrationInput{ + Type: "AWS_PROXY", IntegrationHTTPMethod: "POST", URI: "arn:aws:apigateway:us-east-1:sqs:path/123/q", + }, "Integrations of type 'AWS_PROXY' currently only supports Lambda function and Firehose stream invocations."}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := m.PutIntegration(ctx(), api.ID, res.ID, "GET", tc.in) + assertMessage(t, err, errors.IsInvalidArgument, tc.want) + }) + } + + _, err := m.PutIntegration(ctx(), api.ID, res.ID, "POST", driver.PutIntegrationInput{Type: "MOCK"}) + assertMessage(t, err, errors.IsNotFound, "Invalid Method identifier specified") + + // A stage variable in the host is accepted, and MOCK needs no uri. + for _, in := range []driver.PutIntegrationInput{ + {Type: "HTTP", IntegrationHTTPMethod: "GET", URI: "http://${stageVariables.host}/pets"}, + {Type: "MOCK"}, + } { + if _, err := m.PutIntegration(ctx(), api.ID, res.ID, "GET", in); err != nil { + t.Fatalf("PutIntegration(%+v): %v", in, err) + } + } +} + +func TestCreateStageValidation(t *testing.T) { + m := newMock(t) + apiID, _, _ := deployProxyAPI(t, m, "hello", "GET", lambdaURI) + st, _ := m.GetStage(ctx(), apiID, "prod") + + _, err := m.CreateStage(ctx(), apiID, driver.CreateStageInput{StageName: "prod", DeploymentID: st.DeploymentID}) + assertMessage(t, err, errors.IsAlreadyExists, "Stage already exists") + + _, err = m.CreateStage(ctx(), apiID, driver.CreateStageInput{StageName: "bad.name", DeploymentID: st.DeploymentID}) + assertMessage(t, err, errors.IsInvalidArgument, "Stage name only allows a-zA-Z0-9_-") + + _, err = m.CreateStage(ctx(), apiID, driver.CreateStageInput{StageName: "qa", DeploymentID: "nope"}) + assertMessage(t, err, errors.IsNotFound, "Invalid Deployment identifier specified") + + if _, err := m.CreateStage(ctx(), apiID, driver.CreateStageInput{ + StageName: "qa-1_x", DeploymentID: st.DeploymentID, + }); err != nil { + t.Fatalf("CreateStage(qa-1_x): %v", err) + } +} + +// TestGetDeploymentAPISummary proves a deployment reports the method summary it +// captured, not the live tree. +func TestGetDeploymentAPISummary(t *testing.T) { + m := newMock(t) + apiID, rootID, _ := deployProxyAPI(t, m, "hello", "GET", lambdaURI) + st, _ := m.GetStage(ctx(), apiID, "prod") + + addLambdaMethod(t, m, apiID, rootID, "fresh", lambdaURI) + + dep, err := m.GetDeployment(ctx(), apiID, st.DeploymentID) + if err != nil { + t.Fatalf("GetDeployment: %v", err) + } + + got, ok := dep.APISummary["/hello"]["GET"] + if !ok || got.AuthorizationType != "NONE" { + t.Fatalf("apiSummary = %+v, want /hello GET NONE", dep.APISummary) + } + + if _, leaked := dep.APISummary["/fresh"]; leaked { + t.Fatalf("apiSummary includes an undeployed path: %+v", dep.APISummary) + } +} + +// TestSnapshotRestorePreservesDeploymentTrees proves persist keeps each +// deployment's captured tree, so a restored stage still serves what was +// deployed rather than the live edits. +func TestSnapshotRestorePreservesDeploymentTrees(t *testing.T) { + src := newMock(t) + apiID, rootID, _ := deployProxyAPI(t, src, "hello", "GET", lambdaURI) + addLambdaMethod(t, src, apiID, rootID, "fresh", lambdaURI) + + data, err := src.Snapshot(ctx(), false) + if err != nil { + t.Fatalf("Snapshot: %v", err) + } + + dst := newMock(t) + dst.SetLambdaInvoker(&fakeInvoker{output: []byte(`{"statusCode":200,"body":"ok"}`)}) + + if err := dst.Restore(ctx(), data); err != nil { + t.Fatalf("Restore: %v", err) + } + + if got := invokeStatus(t, dst, apiID, "prod", "/hello"); got != 200 { + t.Fatalf("restored /hello = %d, want 200", got) + } + + if got := invokeStatus(t, dst, apiID, "prod", "/fresh"); got != 403 { + t.Fatalf("restored /fresh (never deployed) = %d, want 403", got) + } +} diff --git a/providers/aws/apigateway/methods.go b/providers/aws/apigateway/methods.go index 81ebf1ca9..844b14eff 100644 --- a/providers/aws/apigateway/methods.go +++ b/providers/aws/apigateway/methods.go @@ -21,12 +21,16 @@ func (m *Mock) PutMethod( res, ok := ad.resources[resourceID] if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "Invalid resource identifier specified %s", resourceID) + return nil, cerrors.New(cerrors.NotFound, msgResourceNotFound) } method := normalizeMethod(httpMethod) - if method == "" { - return nil, cerrors.New(cerrors.InvalidArgument, "httpMethod is required") + if !validHTTPMethod(method) { + return nil, cerrors.New(cerrors.InvalidArgument, msgInvalidHTTPMethod) + } + + if _, exists := res.Methods[method]; exists { + return nil, cerrors.New(cerrors.AlreadyExists, msgMethodExists) } mth := &driver.Method{ @@ -64,12 +68,12 @@ func (m *Mock) DeleteMethod(_ context.Context, restAPIID, resourceID, httpMethod res, ok := ad.resources[resourceID] if !ok { - return cerrors.Newf(cerrors.NotFound, "Invalid resource identifier specified %s", resourceID) + return cerrors.New(cerrors.NotFound, msgResourceNotFound) } method := normalizeMethod(httpMethod) if _, ok := res.Methods[method]; !ok { - return cerrors.Newf(cerrors.NotFound, "Invalid method identifier specified %s", method) + return cerrors.New(cerrors.NotFound, msgMethodNotFound) } delete(res.Methods, method) @@ -82,8 +86,8 @@ func (m *Mock) DeleteMethod(_ context.Context, restAPIID, resourceID, httpMethod func (m *Mock) PutIntegration( _ context.Context, restAPIID, resourceID, httpMethod string, in driver.PutIntegrationInput, ) (*driver.Integration, error) { - if in.Type == "" { - return nil, cerrors.New(cerrors.InvalidArgument, "Integration type is required") + if err := validateIntegration(&in); err != nil { + return nil, err } ad, err := m.getAPI(restAPIID) @@ -96,14 +100,14 @@ func (m *Mock) PutIntegration( res, ok := ad.resources[resourceID] if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "Invalid resource identifier specified %s", resourceID) + return nil, cerrors.New(cerrors.NotFound, msgResourceNotFound) } method := normalizeMethod(httpMethod) mth, ok := res.Methods[method] if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "Invalid method identifier specified %s", method) + return nil, cerrors.New(cerrors.NotFound, msgMethodNotFound) } ig := &driver.Integration{ @@ -128,7 +132,7 @@ func (m *Mock) GetIntegration(_ context.Context, restAPIID, resourceID, httpMeth } if mth.Integration == nil { - return nil, cerrors.New(cerrors.NotFound, "No integration defined for method") + return nil, cerrors.New(cerrors.NotFound, msgIntegrationNotFound) } return mth.Integration, nil @@ -146,18 +150,18 @@ func (m *Mock) DeleteIntegration(_ context.Context, restAPIID, resourceID, httpM res, ok := ad.resources[resourceID] if !ok { - return cerrors.Newf(cerrors.NotFound, "Invalid resource identifier specified %s", resourceID) + return cerrors.New(cerrors.NotFound, msgResourceNotFound) } method := normalizeMethod(httpMethod) mth, ok := res.Methods[method] if !ok { - return cerrors.Newf(cerrors.NotFound, "Invalid method identifier specified %s", method) + return cerrors.New(cerrors.NotFound, msgMethodNotFound) } if mth.Integration == nil { - return cerrors.New(cerrors.NotFound, "No integration defined for method") + return cerrors.New(cerrors.NotFound, msgIntegrationNotFound) } mth.Integration = nil @@ -178,12 +182,12 @@ func (m *Mock) lookupMethod(restAPIID, resourceID, httpMethod string) (*driver.M res, ok := ad.resources[resourceID] if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "Invalid resource identifier specified %s", resourceID) + return nil, cerrors.New(cerrors.NotFound, msgResourceNotFound) } mth, ok := res.Methods[normalizeMethod(httpMethod)] if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "Invalid method identifier specified %s", httpMethod) + return nil, cerrors.New(cerrors.NotFound, msgMethodNotFound) } out := *mth diff --git a/providers/aws/apigateway/patch.go b/providers/aws/apigateway/patch.go index ff2804136..2a438aa62 100644 --- a/providers/aws/apigateway/patch.go +++ b/providers/aws/apigateway/patch.go @@ -109,7 +109,7 @@ func (m *Mock) UpdateResource( res, ok := ad.resources[resourceID] if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "Invalid resource identifier specified %s", resourceID) + return nil, cerrors.New(cerrors.NotFound, msgResourceNotFound) } for _, op := range ops { @@ -122,7 +122,7 @@ func (m *Mock) UpdateResource( } if _, ok := ad.resources[op.Value]; !ok { - return nil, cerrors.Newf(cerrors.NotFound, "Invalid resource identifier specified %s", op.Value) + return nil, cerrors.New(cerrors.NotFound, msgResourceNotFound) } // Reject a move into the resource's own subtree: the target must not @@ -174,12 +174,12 @@ func (m *Mock) UpdateMethod( res, ok := ad.resources[resourceID] if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "Invalid resource identifier specified %s", resourceID) + return nil, cerrors.New(cerrors.NotFound, msgResourceNotFound) } mth, ok := res.Methods[normalizeMethod(httpMethod)] if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "Invalid method identifier specified %s", httpMethod) + return nil, cerrors.New(cerrors.NotFound, msgMethodNotFound) } for _, op := range ops { @@ -210,12 +210,12 @@ func (m *Mock) UpdateIntegration( res, ok := ad.resources[resourceID] if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "Invalid resource identifier specified %s", resourceID) + return nil, cerrors.New(cerrors.NotFound, msgResourceNotFound) } mth, ok := res.Methods[normalizeMethod(httpMethod)] if !ok || mth.Integration == nil { - return nil, cerrors.New(cerrors.NotFound, "No integration defined for method") + return nil, cerrors.New(cerrors.NotFound, msgIntegrationNotFound) } for _, op := range ops { @@ -260,7 +260,7 @@ func (m *Mock) UpdateDeployment( dep, 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) } for _, op := range ops { @@ -310,7 +310,7 @@ func applyStagePatch(ad *apiData, st *driver.Stage, op driver.PatchOperation) er st.Description = op.Value case op.Path == "/deploymentId": if _, ok := ad.deployments[op.Value]; !ok { - return cerrors.Newf(cerrors.NotFound, "Invalid deployment identifier specified %s", op.Value) + return cerrors.New(cerrors.NotFound, msgDeploymentNotFound) } st.DeploymentID = op.Value diff --git a/providers/aws/apigateway/resources.go b/providers/aws/apigateway/resources.go index 535d6d19d..ef4a888c2 100644 --- a/providers/aws/apigateway/resources.go +++ b/providers/aws/apigateway/resources.go @@ -26,7 +26,7 @@ func (m *Mock) CreateResource(_ context.Context, restAPIID, parentID, pathPart s parent, ok := ad.resources[parentID] if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "Invalid resource identifier specified %s", parentID) + return nil, cerrors.New(cerrors.NotFound, msgResourceNotFound) } fullPath := joinPath(parent.Path, pathPart) @@ -93,7 +93,7 @@ func (m *Mock) DeleteResource(_ context.Context, restAPIID, resourceID string) e defer ad.mu.Unlock() if _, ok := ad.resources[resourceID]; !ok { - return cerrors.Newf(cerrors.NotFound, "Invalid resource identifier specified %s", resourceID) + return cerrors.New(cerrors.NotFound, msgResourceNotFound) } if resourceID == ad.api.RootResourceID { @@ -143,7 +143,7 @@ func (m *Mock) GetResource(_ context.Context, restAPIID, resourceID string) (*dr r, ok := ad.resources[resourceID] if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "Invalid resource identifier specified %s", resourceID) + return nil, cerrors.New(cerrors.NotFound, msgResourceNotFound) } out := copyResource(r) diff --git a/providers/aws/apigateway/snapshot.go b/providers/aws/apigateway/snapshot.go index 41b1a7f34..cbf107774 100644 --- a/providers/aws/apigateway/snapshot.go +++ b/providers/aws/apigateway/snapshot.go @@ -20,12 +20,14 @@ type apigatewaySnapshot struct { } // apiSnapshot is the exported form of apiData: the REST API plus its resource -// tree, deployments and stages, all under their original identities. +// tree, deployments (with the tree each one captured) and stages, all under +// their original identities. type apiSnapshot struct { - API driver.RestAPI `json:"api"` - Resources map[string]*driver.Resource `json:"resources,omitempty"` - Deployments map[string]*driver.Deployment `json:"deployments,omitempty"` - Stages map[string]*driver.Stage `json:"stages,omitempty"` + API driver.RestAPI `json:"api"` + Resources map[string]*driver.Resource `json:"resources,omitempty"` + Deployments map[string]*driver.Deployment `json:"deployments,omitempty"` + DeploymentTrees map[string]map[string]*driver.Resource `json:"deploymentTrees,omitempty"` + Stages map[string]*driver.Stage `json:"stages,omitempty"` } // Snapshot captures the mock's entire state as JSON. includeAssets is unused. API Gateway holds @@ -55,6 +57,12 @@ func snapshotAPI(ad *apiData) *apiSnapshot { Resources: make(map[string]*driver.Resource, len(ad.resources)), Deployments: make(map[string]*driver.Deployment, len(ad.deployments)), Stages: make(map[string]*driver.Stage, len(ad.stages)), + + DeploymentTrees: make(map[string]map[string]*driver.Resource, len(ad.trees)), + } + + for did, tree := range ad.trees { + as.DeploymentTrees[did] = copyTree(tree) } for rid, r := range ad.resources { @@ -90,12 +98,15 @@ func (m *Mock) Restore(_ context.Context, data json.RawMessage) error { return nil } -// restoreAPI rebuilds an apiData from its exported snapshot form. +// restoreAPI rebuilds an apiData from its exported snapshot form. A snapshot +// written before deployments captured their own tree has none, so each such +// deployment falls back to a copy of the restored live tree. func restoreAPI(as *apiSnapshot) *apiData { ad := &apiData{ api: as.API, resources: make(map[string]*driver.Resource, len(as.Resources)), deployments: make(map[string]*driver.Deployment, len(as.Deployments)), + trees: make(map[string]map[string]*driver.Resource, len(as.Deployments)), stages: make(map[string]*driver.Stage, len(as.Stages)), } @@ -105,6 +116,12 @@ func restoreAPI(as *apiSnapshot) *apiData { for did, d := range as.Deployments { ad.deployments[did] = d + + if tree, ok := as.DeploymentTrees[did]; ok { + ad.trees[did] = tree + } else { + ad.trees[did] = copyTree(ad.resources) + } } for name, s := range as.Stages { diff --git a/providers/aws/apigateway/validation.go b/providers/aws/apigateway/validation.go new file mode 100644 index 000000000..be79d6e82 --- /dev/null +++ b/providers/aws/apigateway/validation.go @@ -0,0 +1,175 @@ +package apigateway + +import ( + "net/url" + "strings" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/services/apigateway/driver" +) + +// Error messages real API Gateway returns for the validations below. +const ( + msgResourceNotFound = "Invalid Resource identifier specified" + msgMethodNotFound = "Invalid Method identifier specified" + msgIntegrationNotFound = "Invalid Integration identifier specified" + msgDeploymentNotFound = "Invalid Deployment identifier specified" + msgInvalidHTTPMethod = "Invalid HTTP method specified" + msgMethodExists = "Method already exists for this resource" + msgStageExists = "Stage already exists" + msgStageName = "Stage name only allows a-zA-Z0-9_-" + msgNoMethods = "The REST API doesn't contain any methods" + msgNoIntegration = "No integration defined for method" + msgEmptyHTTPMethod = "Enumeration value for HttpMethod must be non-empty" + msgInvalidHTTPEndpoint = "Invalid HTTP endpoint specified for URI" + msgInvalidARN = "Invalid ARN specified in the request" + msgARNPathOrAction = "AWS ARN for integration must contain path or action" + msgAWSProxyTarget = "Integrations of type 'AWS_PROXY' currently only supports " + + "Lambda function and Firehose stream invocations." + msgIntegrationTypeFmt = "1 validation error detected: Value '%s' at 'putIntegrationInput.type' " + + "failed to satisfy constraint: Member must satisfy enum value set: [HTTP, AWS, MOCK, HTTP_PROXY, AWS_PROXY]" +) + +// maxStageNameLen is the longest stage name API Gateway accepts. +const maxStageNameLen = 128 + +// validHTTPMethod reports whether method is one PutMethod accepts. +func validHTTPMethod(method string) bool { + switch method { + case "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", driver.MethodANY: + return true + default: + return false + } +} + +// validateStageName enforces the stage-name rule: alphanumerics, hyphens and +// underscores, at most 128 characters. +func validateStageName(name string) error { + if name == "" || len(name) > maxStageNameLen { + return cerrors.New(cerrors.InvalidArgument, msgStageName) + } + + for _, c := range name { + if !isStageNameChar(c) { + return cerrors.New(cerrors.InvalidArgument, msgStageName) + } + } + + return nil +} + +func isStageNameChar(c rune) bool { + return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_' +} + +// validateIntegration applies PutIntegration's type, httpMethod and uri rules. +func validateIntegration(in *driver.PutIntegrationInput) error { + switch in.Type { + case driver.IntegrationMock: + return nil + case driver.IntegrationHTTP, driver.IntegrationHTTPProxy, driver.IntegrationAWS, driver.IntegrationAWSProxy: + default: + return cerrors.Newf(cerrors.InvalidArgument, msgIntegrationTypeFmt, in.Type) + } + + if in.IntegrationHTTPMethod == "" { + return cerrors.New(cerrors.InvalidArgument, msgEmptyHTTPMethod) + } + + if in.Type == driver.IntegrationHTTP || in.Type == driver.IntegrationHTTPProxy { + return validateHTTPEndpoint(in.URI) + } + + return validateAWSIntegrationARN(in.Type, in.URI) +} + +// validateHTTPEndpoint requires an absolute http(s) URL. Stage-variable +// placeholders are allowed anywhere, including the host. +func validateHTTPEndpoint(uri string) error { + u, err := url.Parse(substituteStageVariables(uri, nil, "x")) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return cerrors.New(cerrors.InvalidArgument, msgInvalidHTTPEndpoint) + } + + return nil +} + +// validateAWSIntegrationARN checks an AWS/AWS_PROXY integration uri of the form +// arn:aws:apigateway:{region}:{service}:{path|action}/{...}. +func validateAWSIntegrationARN(integrationType, uri string) error { + const arnParts = 6 + + parts := strings.SplitN(uri, ":", arnParts) + if len(parts) != arnParts || parts[0] != "arn" || parts[2] != "apigateway" { + return cerrors.New(cerrors.InvalidArgument, msgInvalidARN) + } + + service, target := parts[4], parts[5] + if !strings.HasPrefix(target, "path/") && !strings.HasPrefix(target, "action/") { + return cerrors.New(cerrors.InvalidArgument, msgARNPathOrAction) + } + + if integrationType == driver.IntegrationAWSProxy && service != "lambda" && service != "firehose" { + return cerrors.New(cerrors.InvalidArgument, msgAWSProxyTarget) + } + + return nil +} + +// validateDeployable rejects a CreateDeployment of a tree with no methods, or +// with a method that has no integration. +func validateDeployable(resources map[string]*driver.Resource) error { + methods := 0 + + for _, r := range resources { + for _, mth := range r.Methods { + if mth.Integration == nil { + return cerrors.New(cerrors.InvalidArgument, msgNoIntegration) + } + + methods++ + } + } + + if methods == 0 { + return cerrors.New(cerrors.InvalidArgument, msgNoMethods) + } + + return nil +} + +// substituteStageVariables replaces each ${stageVariables.name} in s with the +// stage's value. An unknown name resolves to fallback (empty on invoke, as API +// Gateway does). +func substituteStageVariables(s string, vars map[string]string, fallback string) string { + const open = "${stageVariables." + + var b strings.Builder + + for { + i := strings.Index(s, open) + if i < 0 { + b.WriteString(s) + + return b.String() + } + + end := strings.IndexByte(s[i:], '}') + if end < 0 { + b.WriteString(s) + + return b.String() + } + + b.WriteString(s[:i]) + + if v, ok := vars[s[i+len(open):i+end]]; ok { + b.WriteString(v) + } else { + b.WriteString(fallback) + } + + s = s[i+end+1:] + } +} diff --git a/server/aws/apigateway/apigateway_e2e_test.go b/server/aws/apigateway/apigateway_e2e_test.go index 68546442f..344707687 100644 --- a/server/aws/apigateway/apigateway_e2e_test.go +++ b/server/aws/apigateway/apigateway_e2e_test.go @@ -104,7 +104,7 @@ func buildProxyAPI(t *testing.T, base string) string { `{"authorizationType":"NONE"}`) doJSON(t, http.MethodPut, base+"/restapis/"+apiID+"/resources/"+resID+"/methods/ANY/integration", - `{"type":"AWS_PROXY","integrationHttpMethod":"POST","uri":"`+lambdaURI+`"}`) + `{"type":"AWS_PROXY","httpMethod":"POST","uri":"`+lambdaURI+`"}`) doJSON(t, http.MethodPost, base+"/restapis/"+apiID+"/deployments", `{"stageName":"prod"}`) @@ -184,9 +184,12 @@ func TestE2E_HostFormRouting(t *testing.T) { func TestE2E_UndefinedRouteForbidden(t *testing.T) { srv := newE2E(t) - // A REST API that exists but has no matching resource for this path. + // A deployed REST API that has no resource matching this path. api := doJSON(t, http.MethodPost, srv.URL+"/restapis", `{"name":"empty"}`) apiID, _ := api["id"].(string) + rootID, _ := api["rootResourceId"].(string) + doJSON(t, http.MethodPut, srv.URL+"/restapis/"+apiID+"/resources/"+rootID+"/methods/GET", `{"authorizationType":"NONE"}`) + doJSON(t, http.MethodPut, srv.URL+"/restapis/"+apiID+"/resources/"+rootID+"/methods/GET/integration", `{"type":"MOCK"}`) doJSON(t, http.MethodPost, srv.URL+"/restapis/"+apiID+"/deployments", `{"stageName":"prod"}`) resp, err := http.Get(srv.URL + "/restapis/" + apiID + "/prod/_user_request_/anything") //nolint:noctx // test @@ -301,7 +304,7 @@ func TestE2E_ControlPlaneRoundTrip(t *testing.T) { apiID, _ := api["id"].(string) got := doJSON(t, http.MethodGet, srv.URL+"/restapis/"+apiID, "") - if got["name"] != "petstore" || got["description"] != "d" { + if got["name"] != "petstore" || got["description"] != "d" || got["apiStatus"] != "AVAILABLE" { t.Fatalf("GetRestApi mismatch: %v", got) } diff --git a/server/aws/apigateway/deployment_e2e_test.go b/server/aws/apigateway/deployment_e2e_test.go new file mode 100644 index 000000000..125923691 --- /dev/null +++ b/server/aws/apigateway/deployment_e2e_test.go @@ -0,0 +1,207 @@ +package apigateway_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" +) + +// doRaw sends a request and returns the status, body and error-type header +// without failing on a non-2xx status. +func doRaw(t *testing.T, method, url, body string) (status int, raw []byte, errType string) { + t.Helper() + + req, _ := http.NewRequestWithContext(context.Background(), method, url, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, url, err) + } + + defer resp.Body.Close() + + raw, _ = io.ReadAll(resp.Body) + + return resp.StatusCode, raw, resp.Header.Get("X-Amzn-Errortype") +} + +func assertWireError(t *testing.T, method, url, body string, wantStatus int, wantType, wantMsg string) { + t.Helper() + + status, raw, errType := doRaw(t, method, url, body) + if status != wantStatus || errType != wantType { + t.Fatalf("%s %s = %d %s, want %d %s: %s", method, url, status, errType, wantStatus, wantType, raw) + } + + var out struct { + Message string `json:"message"` + } + _ = json.Unmarshal(raw, &out) + + if out.Message != wantMsg { + t.Fatalf("%s %s message = %q, want %q", method, url, out.Message, wantMsg) + } +} + +// TestExecuteAPIHostForV2APIServedByV1UntilV2DataPlane pins today's behaviour +// for an HTTP API id on an execute-api host: the v1 handler owns the host, does +// not know the id and answers 403 Missing Authentication Token. The v2 data +// plane replaces this test when it lands. +func TestExecuteAPIHostForV2APIServedByV1UntilV2DataPlane(t *testing.T) { + srv := newE2E(t) + + api := doJSON(t, http.MethodPost, srv.URL+"/v2/apis", `{"name":"http","protocolType":"HTTP"}`) + + v2ID, _ := api["apiId"].(string) + if v2ID == "" { + t.Fatalf("CreateApi gave no apiId: %v", api) + } + + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL+"/$default/x", nil) + req.Host = v2ID + ".execute-api.localhost" + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("execute-api request: %v", err) + } + + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusForbidden || string(body) != `{"message":"Missing Authentication Token"}` { + t.Fatalf("v2 id on execute-api host = %d %s, want 403 Missing Authentication Token", resp.StatusCode, body) + } +} + +// TestE2E_DeployedStageIgnoresLiveEdits proves over the wire that a stage keeps +// serving its deployment until the API is redeployed. +func TestE2E_DeployedStageIgnoresLiveEdits(t *testing.T) { + srv := newE2E(t) + base := srv.URL + apiID := buildProxyAPI(t, base) + invoke := base + "/restapis/" + apiID + "/prod/_user_request_/pets" + + resources := doJSON(t, http.MethodGet, base+"/restapis/"+apiID+"/resources", "") + items, _ := resources["item"].([]any) + + var proxyID string + + for _, it := range items { + r, _ := it.(map[string]any) + if r["path"] == "/{proxy+}" { + proxyID, _ = r["id"].(string) + } + } + + deleteOK(t, base+"/restapis/"+apiID+"/resources/"+proxyID+"/methods/ANY") + + if status, raw, _ := doRaw(t, http.MethodGet, invoke, ""); status != http.StatusOK { + t.Fatalf("deployed route after live delete = %d, want 200: %s", status, raw) + } + + // The live API now has no methods at all, so it cannot be redeployed. + assertWireError(t, http.MethodPost, base+"/restapis/"+apiID+"/deployments", `{"stageName":"prod"}`, + http.StatusBadRequest, "BadRequestException", "The REST API doesn't contain any methods") +} + +// TestE2E_ResourceMethodsEmbed proves GetResources lists only method names +// unless ?embed=methods asks for the full Method objects. +func TestE2E_ResourceMethodsEmbed(t *testing.T) { + srv := newE2E(t) + base := srv.URL + apiID := buildProxyAPI(t, base) + + proxyMethod := func(url string) map[string]any { + out := doJSON(t, http.MethodGet, url, "") + items, _ := out["item"].([]any) + + for _, it := range items { + r, _ := it.(map[string]any) + if r["path"] == "/{proxy+}" { + methods, _ := r["resourceMethods"].(map[string]any) + m, _ := methods["ANY"].(map[string]any) + + return m + } + } + + t.Fatalf("no /{proxy+} resource in %v", out) + + return nil + } + + if m := proxyMethod(base + "/restapis/" + apiID + "/resources"); m == nil || len(m) != 0 { + t.Fatalf("resourceMethods without embed = %v, want {\"ANY\":{}}", m) + } + + m := proxyMethod(base + "/restapis/" + apiID + "/resources?embed=methods") + if m["httpMethod"] != "ANY" || m["methodIntegration"] == nil { + t.Fatalf("resourceMethods with embed=methods = %v, want the full method", m) + } +} + +// TestE2E_DeploymentAPISummaryEmbed proves GetDeployment returns apiSummary only +// when asked with ?embed=apisummary. +func TestE2E_DeploymentAPISummaryEmbed(t *testing.T) { + srv := newE2E(t) + base := srv.URL + apiID := buildProxyAPI(t, base) + + stage := doJSON(t, http.MethodGet, base+"/restapis/"+apiID+"/stages/prod", "") + depURL := base + "/restapis/" + apiID + "/deployments/" + stage["deploymentId"].(string) + + if plain := doJSON(t, http.MethodGet, depURL, ""); plain["apiSummary"] != nil { + t.Fatalf("apiSummary returned without embed: %v", plain) + } + + got := doJSON(t, http.MethodGet, depURL+"?embed=apisummary", "") + summary, _ := got["apiSummary"].(map[string]any) + path, _ := summary["/{proxy+}"].(map[string]any) + method, _ := path["ANY"].(map[string]any) + + if method["authorizationType"] != "NONE" { + t.Fatalf("apiSummary = %v, want /{proxy+} ANY NONE", got["apiSummary"]) + } +} + +// TestE2E_ValidationErrors checks the BadRequest and Conflict shapes the +// method, integration, deployment and stage validations return on the wire. +func TestE2E_ValidationErrors(t *testing.T) { + srv := newE2E(t) + base := srv.URL + + api := doJSON(t, http.MethodPost, base+"/restapis", `{"name":"v"}`) + apiID, _ := api["id"].(string) + rootID, _ := api["rootResourceId"].(string) + methods := base + "/restapis/" + apiID + "/resources/" + rootID + "/methods/" + + assertWireError(t, http.MethodPost, base+"/restapis/"+apiID+"/deployments", `{"stageName":"prod"}`, + http.StatusBadRequest, "BadRequestException", "The REST API doesn't contain any methods") + + assertWireError(t, http.MethodPut, methods+"FOO", `{"authorizationType":"NONE"}`, + http.StatusBadRequest, "BadRequestException", "Invalid HTTP method specified") + + doJSON(t, http.MethodPut, methods+"GET", `{"authorizationType":"NONE"}`) + + assertWireError(t, http.MethodPut, methods+"GET", `{"authorizationType":"NONE"}`, + http.StatusConflict, "ConflictException", "Method already exists for this resource") + + assertWireError(t, http.MethodPost, base+"/restapis/"+apiID+"/deployments", `{"stageName":"prod"}`, + http.StatusBadRequest, "BadRequestException", "No integration defined for method") + + assertWireError(t, http.MethodPut, methods+"GET/integration", `{"type":"HTTP","uri":"http://example.com"}`, + http.StatusBadRequest, "BadRequestException", "Enumeration value for HttpMethod must be non-empty") + + doJSON(t, http.MethodPut, methods+"GET/integration", `{"type":"MOCK"}`) + + dep := doJSON(t, http.MethodPost, base+"/restapis/"+apiID+"/deployments", `{"stageName":"prod"}`) + depID, _ := dep["id"].(string) + + assertWireError(t, http.MethodPost, base+"/restapis/"+apiID+"/stages", `{"stageName":"prod","deploymentId":"`+depID+`"}`, + http.StatusConflict, "ConflictException", "Stage already exists") +} diff --git a/server/aws/apigateway/handler.go b/server/aws/apigateway/handler.go index 48e0686b6..fa80a0e8d 100644 --- a/server/aws/apigateway/handler.go +++ b/server/aws/apigateway/handler.go @@ -289,9 +289,11 @@ func (h *Handler) getResources(w http.ResponseWriter, r *http.Request, id string return } + render := resourceRenderer(r) + out := listResourcesResponse{Item: make([]resourceResponse, 0, len(resources))} for i := range resources { - out.Item = append(out.Item, toResourceResponse(&resources[i])) + out.Item = append(out.Item, render(&resources[i])) } writeJSON(w, http.StatusOK, out) @@ -318,7 +320,7 @@ func (h *Handler) serveResourceItem(w http.ResponseWriter, r *http.Request, id, return } - writeJSON(w, http.StatusOK, toResourceResponse(res)) + writeJSON(w, http.StatusOK, resourceRenderer(r)(res)) case http.MethodPost: var req createResourceRequest if !decodeJSON(w, r, &req) { @@ -472,16 +474,45 @@ func (h *Handler) getDeployments(w http.ResponseWriter, r *http.Request, id stri // //nolint:dupl // parallel item router for deployments vs stages; the shared serveItem shape is intentional func (h *Handler) serveDeploymentItem(w http.ResponseWriter, r *http.Request, id, deploymentID string) { + render := toDeploymentResponse + if r.Method == http.MethodGet && hasEmbed(r, "apisummary") { + render = toDeploymentSummaryResponse + } + serveItem(w, r, func(ops []driver.PatchOperation) (*driver.Deployment, error) { return h.ag.UpdateDeployment(r.Context(), id, deploymentID, ops) }, func() (*driver.Deployment, error) { return h.ag.GetDeployment(r.Context(), id, deploymentID) }, func() error { return h.ag.DeleteDeployment(r.Context(), id, deploymentID) }, - toDeploymentResponse, + render, ) } +// resourceRenderer picks the resource rendering a GetResource(s) request asks +// for: full methods under embed=methods, method names only otherwise. +func resourceRenderer(r *http.Request) func(*driver.Resource) resourceResponse { + if hasEmbed(r, "methods") { + return toEmbeddedResourceResponse + } + + return toResourceResponse +} + +// hasEmbed reports whether the request's embed query parameter (repeated or +// comma-separated) names want. +func hasEmbed(r *http.Request, want string) bool { + for _, v := range r.URL.Query()["embed"] { + for _, e := range strings.Split(v, ",") { + if strings.EqualFold(strings.TrimSpace(e), want) { + return true + } + } + } + + return false +} + func (h *Handler) createDeployment(w http.ResponseWriter, r *http.Request, id string) { if r.Method != http.MethodPost { writeMethodNotAllowed(w) @@ -494,7 +525,8 @@ func (h *Handler) createDeployment(w http.ResponseWriter, r *http.Request, id st } dep, err := h.ag.CreateDeployment(r.Context(), id, driver.CreateDeploymentInput{ - StageName: req.StageName, Description: req.Description, + StageName: req.StageName, StageDescription: req.StageDescription, + Description: req.Description, Variables: req.Variables, }) if err != nil { writeErr(w, err) diff --git a/server/aws/apigateway/types.go b/server/aws/apigateway/types.go index dc1dfa36c..c5a08f186 100644 --- a/server/aws/apigateway/types.go +++ b/server/aws/apigateway/types.go @@ -56,10 +56,12 @@ type putMethodRequest struct { APIKeyRequired bool `json:"apiKeyRequired"` } -// putIntegrationRequest is the PutIntegration request body. +// putIntegrationRequest is the PutIntegration request body. The integration's +// backend method travels as "httpMethod" on the wire (the model's locationName +// for integrationHttpMethod). type putIntegrationRequest struct { Type string `json:"type"` - IntegrationHTTPMethod string `json:"integrationHttpMethod"` + IntegrationHTTPMethod string `json:"httpMethod"` URI string `json:"uri"` PassthroughBehavior string `json:"passthroughBehavior"` TimeoutInMillis int `json:"timeoutInMillis"` @@ -67,8 +69,10 @@ type putIntegrationRequest struct { // createDeploymentRequest is the CreateDeployment request body. type createDeploymentRequest struct { - StageName string `json:"stageName"` - Description string `json:"description"` + StageName string `json:"stageName"` + StageDescription string `json:"stageDescription"` + Description string `json:"description"` + Variables map[string]string `json:"variables"` } // createStageRequest is the CreateStage request body. @@ -79,6 +83,9 @@ type createStageRequest struct { Variables map[string]string `json:"variables"` } +// apiStatusAvailable is the RestApi apiStatus of a ready API. +const apiStatusAvailable = "AVAILABLE" + // restAPIResponse is the RestApi wire object. type restAPIResponse struct { ID string `json:"id"` @@ -87,6 +94,7 @@ type restAPIResponse struct { Version string `json:"version,omitempty"` CreatedDate int64 `json:"createdDate"` RootResourceID string `json:"rootResourceId"` + APIStatus string `json:"apiStatus"` APIKeySource string `json:"apiKeySource,omitempty"` Tags map[string]string `json:"tags,omitempty"` BinaryMediaTypes []string `json:"binaryMediaTypes,omitempty"` @@ -101,13 +109,15 @@ type listRestAPIsResponse struct { Item []restAPIResponse `json:"item"` } -// resourceResponse is the Resource wire object. +// resourceResponse is the Resource wire object. ResourceMethods holds a full +// methodResponse per method under embed=methods, and an empty object per method +// otherwise. type resourceResponse struct { - ID string `json:"id"` - ParentID string `json:"parentId,omitempty"` - PathPart string `json:"pathPart,omitempty"` - Path string `json:"path"` - ResourceMethods map[string]methodResponse `json:"resourceMethods,omitempty"` + ID string `json:"id"` + ParentID string `json:"parentId,omitempty"` + PathPart string `json:"pathPart,omitempty"` + Path string `json:"path"` + ResourceMethods map[string]any `json:"resourceMethods,omitempty"` } // listResourcesResponse is the GetResources wire object. @@ -132,11 +142,19 @@ type integrationResponse struct { TimeoutInMillis int `json:"timeoutInMillis,omitempty"` } -// deploymentResponse is the Deployment wire object. +// deploymentResponse is the Deployment wire object. APISummary is only sent +// for GetDeployment with embed=apisummary. type deploymentResponse struct { - ID string `json:"id"` - Description string `json:"description,omitempty"` - CreatedDate int64 `json:"createdDate"` + ID string `json:"id"` + Description string `json:"description,omitempty"` + CreatedDate int64 `json:"createdDate"` + APISummary map[string]map[string]methodSnapshot `json:"apiSummary,omitempty"` +} + +// methodSnapshot is one method's entry in a deployment's apiSummary. +type methodSnapshot struct { + AuthorizationType string `json:"authorizationType,omitempty"` + APIKeyRequired bool `json:"apiKeyRequired"` } // listDeploymentsResponse is the GetDeployments wire object. @@ -159,9 +177,11 @@ type listStagesResponse struct { } func toRestAPIResponse(a *driver.RestAPI) restAPIResponse { + // An in-memory API is usable as soon as it exists, so apiStatus is always + // AVAILABLE. The AWS provider waits on it before creating children. resp := restAPIResponse{ ID: a.ID, Name: a.Name, Description: a.Description, Version: a.Version, - CreatedDate: a.CreatedDate, RootResourceID: a.RootResourceID, + CreatedDate: a.CreatedDate, RootResourceID: a.RootResourceID, APIStatus: apiStatusAvailable, APIKeySource: a.APIKeySource, Tags: a.Tags, BinaryMediaTypes: a.BinaryMediaTypes, DisableExecuteAPIEndpoint: a.DisableExecuteAPIEndpoint, MinimumCompressionSize: a.MinimumCompressionSize, Policy: a.Policy, @@ -173,13 +193,30 @@ func toRestAPIResponse(a *driver.RestAPI) restAPIResponse { return resp } +// toResourceResponse renders a resource. Its methods are listed by name with +// an empty object each, as API Gateway does unless embed=methods is requested. func toResourceResponse(r *driver.Resource) resourceResponse { + return renderResource(r, false) +} + +// toEmbeddedResourceResponse renders a resource with each method's full Method +// object, the embed=methods form. +func toEmbeddedResourceResponse(r *driver.Resource) resourceResponse { + return renderResource(r, true) +} + +func renderResource(r *driver.Resource, embedMethods bool) resourceResponse { resp := resourceResponse{ID: r.ID, ParentID: r.ParentID, PathPart: r.PathPart, Path: r.Path} if len(r.Methods) > 0 { - resp.ResourceMethods = make(map[string]methodResponse, len(r.Methods)) + resp.ResourceMethods = make(map[string]any, len(r.Methods)) + for name, mth := range r.Methods { - resp.ResourceMethods[name] = toMethodResponse(mth) + if embedMethods { + resp.ResourceMethods[name] = toMethodResponse(mth) + } else { + resp.ResourceMethods[name] = struct{}{} + } } } @@ -212,6 +249,24 @@ func toDeploymentResponse(d *driver.Deployment) deploymentResponse { return deploymentResponse{ID: d.ID, Description: d.Description, CreatedDate: d.CreatedDate} } +// toDeploymentSummaryResponse renders a deployment with its apiSummary, the +// embed=apisummary form. +func toDeploymentSummaryResponse(d *driver.Deployment) deploymentResponse { + resp := toDeploymentResponse(d) + resp.APISummary = make(map[string]map[string]methodSnapshot, len(d.APISummary)) + + for path, methods := range d.APISummary { + out := make(map[string]methodSnapshot, len(methods)) + for name, ms := range methods { + out[name] = methodSnapshot{AuthorizationType: ms.AuthorizationType, APIKeyRequired: ms.APIKeyRequired} + } + + resp.APISummary[path] = out + } + + return resp +} + func toStageResponse(s *driver.Stage) stageResponse { return stageResponse{ StageName: s.StageName, DeploymentID: s.DeploymentID, Description: s.Description, diff --git a/services/apigateway/driver/driver.go b/services/apigateway/driver/driver.go index de962c788..08f804cc7 100644 --- a/services/apigateway/driver/driver.go +++ b/services/apigateway/driver/driver.go @@ -92,11 +92,21 @@ type Integration struct { } // Deployment is a point-in-time snapshot of a REST API published to a stage. +// The resource tree captured at CreateDeployment time is what every stage +// pointing at the deployment serves. APISummary is that tree's +// path -> method -> summary view, filled by GetDeployment only. type Deployment struct { ID string RestAPIID string Description string CreatedDate int64 + APISummary map[string]map[string]MethodSnapshot +} + +// MethodSnapshot is one method's entry in a deployment's APISummary. +type MethodSnapshot struct { + AuthorizationType string + APIKeyRequired bool } // Stage is a named, addressable deployment of a REST API (e.g. "prod"). @@ -141,10 +151,13 @@ type PutIntegrationInput struct { // CreateDeploymentInput carries the fields CreateDeployment accepts. A non-empty // StageName auto-creates (or re-points) that stage to the new deployment, exactly -// as the real CreateDeployment does. +// as the real CreateDeployment does. StageDescription applies when the stage is +// created; Variables are merged into the stage's variables. type CreateDeploymentInput struct { - StageName string - Description string + StageName string + StageDescription string + Description string + Variables map[string]string } // CreateStageInput carries the fields CreateStage accepts. @@ -235,9 +248,9 @@ type APIGateway interface { UpdateStage(ctx context.Context, restAPIID, stageName string, ops []PatchOperation) (*Stage, error) DeleteStage(ctx context.Context, restAPIID, stageName string) error - // InvokeRoute resolves req.HTTPMethod+req.Path against the deployed stage's - // resource tree ({proxy+} greedy paths and {param} placeholders supported) - // and, for an AWS_PROXY/AWS Lambda integration, invokes the target function - // and returns its mapped HTTP response. + // InvokeRoute routes req through the tree its stage's deployment captured. + // It resolves req.HTTPMethod+req.Path ({proxy+} greedy paths and {param} + // placeholders supported) and, for an AWS_PROXY/AWS Lambda integration, + // invokes the target function and returns its mapped HTTP response. InvokeRoute(ctx context.Context, req *ProxyRequest) (*ProxyResponse, error) }