diff --git a/docs/coverage/README.md b/docs/coverage/README.md index e1981001b..876167096 100644 --- a/docs/coverage/README.md +++ b/docs/coverage/README.md @@ -15,7 +15,7 @@ code does not implement. Machine-readable: [`coverage.json`](./coverage.json). | `acm` | [ACM](./aws/acm.md) | - | - | - | 17 | | `aks` | - | [AKS](./azure/aks.md) | - | - | 18 | | `aoss` | [AOSS](./aws/aoss.md) | - | - | - | 18 | -| `apigateway` | [APIGateway](./aws/apigateway.md) | - | - | - | 29 | +| `apigateway` | [APIGateway](./aws/apigateway.md) | - | - | - | 50 | | `apigatewaygcp` | - | - | [APIGateway](./gcp/apigateway.md) | - | 16 | | `apigatewayv2` | [APIGatewayV2](./aws/apigatewayv2.md) | - | - | - | 28 | | `appconfiguration` | - | [AppConfiguration](./azure/appconfiguration.md) | - | - | 9 | diff --git a/docs/coverage/aws/README.md b/docs/coverage/aws/README.md index 3fbf7f3fc..8d8dd7617 100644 --- a/docs/coverage/aws/README.md +++ b/docs/coverage/aws/README.md @@ -7,7 +7,7 @@ Services cloudemu emulates for AWS, by native name. Back to the [cross-provider | --- | --- | --- | | [ACM](./acm.md) | `acm` | 17 | | [AOSS](./aoss.md) | `aoss` | 18 | -| [APIGateway](./apigateway.md) | `apigateway` | 29 | +| [APIGateway](./apigateway.md) | `apigateway` | 50 | | [APIGatewayV2](./apigatewayv2.md) | `apigatewayv2` | 28 | | [APS](./aps.md) | `aps` | 21 | | [AppFlow](./appflow.md) | `appflow` | 14 | diff --git a/docs/coverage/aws/apigateway.md b/docs/coverage/aws/apigateway.md index 2f4cdeccf..98b04c86f 100644 --- a/docs/coverage/aws/apigateway.md +++ b/docs/coverage/aws/apigateway.md @@ -3,22 +3,35 @@ AWS's `apigateway` service · portable interface `driver.APIGateway` · [AWS index](./README.md) -## Operations (29) +## Operations (50) | Operation | Description | | --- | --- | | `CreateDeployment` | | +| `CreateDocumentationPart` | | +| `CreateDocumentationVersion` | CreateDocumentationVersion snapshots the API's current documentation | | `CreateResource` | | | `CreateRestAPI` | | | `CreateStage` | | +| `DeleteClientCertificate` | DeleteClientCertificate removes a certificate. It fails while a stage | | `DeleteDeployment` | DeleteDeployment removes a deployment. It fails with a FailedPrecondition | +| `DeleteDocumentationPart` | | +| `DeleteDocumentationVersion` | DeleteDocumentationVersion removes a version. It fails while a stage | | `DeleteIntegration` | | | `DeleteMethod` | | | `DeleteResource` | DeleteResource removes a resource and its whole descendant subtree, as | | `DeleteRestAPI` | | | `DeleteStage` | | +| `GenerateClientCertificate` | GenerateClientCertificate creates a self-signed client certificate valid | +| `GetAccount` | | +| `GetClientCertificate` | | +| `GetClientCertificates` | | | `GetDeployment` | | | `GetDeployments` | | +| `GetDocumentationPart` | | +| `GetDocumentationParts` | | +| `GetDocumentationVersion` | | +| `GetDocumentationVersions` | | | `GetIntegration` | | | `GetMethod` | | | `GetResource` | | @@ -27,10 +40,18 @@ AWS's `apigateway` service · portable interface `driver.APIGateway` · [AWS ind | `GetRestAPIs` | | | `GetStage` | | | `GetStages` | | +| `GetTags` | | +| `ImportDocumentationParts` | | | `InvokeRoute` | InvokeRoute routes req through the tree its stage's deployment captured. | | `PutIntegration` | | | `PutMethod` | | +| `TagResource` | TagResource, UntagResource and GetTags manage tags on a REST API or client | +| `UntagResource` | | +| `UpdateAccount` | UpdateAccount applies a patchOperations document (/cloudwatchRoleArn | +| `UpdateClientCertificate` | UpdateClientCertificate applies a patchOperations document (only | | `UpdateDeployment` | UpdateDeployment applies a patchOperations document to a deployment | +| `UpdateDocumentationPart` | UpdateDocumentationPart applies a patchOperations document (only | +| `UpdateDocumentationVersion` | UpdateDocumentationVersion applies a patchOperations document (only | | `UpdateIntegration` | UpdateIntegration applies a patchOperations document to an integration. | | `UpdateMethod` | UpdateMethod applies a patchOperations document to a method. | | `UpdateResource` | UpdateResource applies a patchOperations document to a resource (rename via | diff --git a/docs/coverage/coverage.json b/docs/coverage/coverage.json index 9ed952035..986a1ec10 100644 --- a/docs/coverage/coverage.json +++ b/docs/coverage/coverage.json @@ -269,6 +269,13 @@ { "name": "CreateDeployment" }, + { + "name": "CreateDocumentationPart" + }, + { + "name": "CreateDocumentationVersion", + "doc": "CreateDocumentationVersion snapshots the API's current documentation" + }, { "name": "CreateResource" }, @@ -278,10 +285,21 @@ { "name": "CreateStage" }, + { + "name": "DeleteClientCertificate", + "doc": "DeleteClientCertificate removes a certificate. It fails while a stage" + }, { "name": "DeleteDeployment", "doc": "DeleteDeployment removes a deployment. It fails with a FailedPrecondition" }, + { + "name": "DeleteDocumentationPart" + }, + { + "name": "DeleteDocumentationVersion", + "doc": "DeleteDocumentationVersion removes a version. It fails while a stage" + }, { "name": "DeleteIntegration" }, @@ -298,12 +316,37 @@ { "name": "DeleteStage" }, + { + "name": "GenerateClientCertificate", + "doc": "GenerateClientCertificate creates a self-signed client certificate valid" + }, + { + "name": "GetAccount" + }, + { + "name": "GetClientCertificate" + }, + { + "name": "GetClientCertificates" + }, { "name": "GetDeployment" }, { "name": "GetDeployments" }, + { + "name": "GetDocumentationPart" + }, + { + "name": "GetDocumentationParts" + }, + { + "name": "GetDocumentationVersion" + }, + { + "name": "GetDocumentationVersions" + }, { "name": "GetIntegration" }, @@ -328,6 +371,12 @@ { "name": "GetStages" }, + { + "name": "GetTags" + }, + { + "name": "ImportDocumentationParts" + }, { "name": "InvokeRoute", "doc": "InvokeRoute routes req through the tree its stage's deployment captured." @@ -338,10 +387,33 @@ { "name": "PutMethod" }, + { + "name": "TagResource", + "doc": "TagResource, UntagResource and GetTags manage tags on a REST API or client" + }, + { + "name": "UntagResource" + }, + { + "name": "UpdateAccount", + "doc": "UpdateAccount applies a patchOperations document (/cloudwatchRoleArn" + }, + { + "name": "UpdateClientCertificate", + "doc": "UpdateClientCertificate applies a patchOperations document (only" + }, { "name": "UpdateDeployment", "doc": "UpdateDeployment applies a patchOperations document to a deployment" }, + { + "name": "UpdateDocumentationPart", + "doc": "UpdateDocumentationPart applies a patchOperations document (only" + }, + { + "name": "UpdateDocumentationVersion", + "doc": "UpdateDocumentationVersion applies a patchOperations document (only" + }, { "name": "UpdateIntegration", "doc": "UpdateIntegration applies a patchOperations document to an integration." diff --git a/providers/aws/apigateway/account.go b/providers/aws/apigateway/account.go new file mode 100644 index 000000000..f69e1639d --- /dev/null +++ b/providers/aws/apigateway/account.go @@ -0,0 +1,99 @@ +package apigateway + +import ( + "context" + "regexp" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/services/apigateway/driver" +) + +// Account defaults of a fresh region: 10000 requests/second with a burst of +// 5000, usage plans enabled and API key version 4. +const ( + defaultRateLimit = 10000 + defaultBurstLimit = 5000 + featureUsagePlans = "UsagePlans" + defaultAPIKeyVersion = "4" + + pathCloudWatchRoleARN = "/cloudwatchRoleArn" + pathFeatures = "/features" +) + +// roleARNPattern is the shape of an IAM role ARN. +var roleARNPattern = regexp.MustCompile(`^arn:aws[a-zA-Z-]*:iam::\d{12}:role/.+$`) + +func defaultAccount() driver.Account { + return driver.Account{ + Throttle: driver.ThrottleSettings{BurstLimit: defaultBurstLimit, RateLimit: defaultRateLimit}, + Features: []string{featureUsagePlans}, + APIKeyVersion: defaultAPIKeyVersion, + } +} + +// GetAccount returns the region's account settings. +func (m *Mock) GetAccount(_ context.Context) (*driver.Account, error) { + m.regionMu.RLock() + defer m.regionMu.RUnlock() + + out := copyAccount(&m.account) + + return &out, nil +} + +// UpdateAccount applies a patch document. Only the CloudWatch role and the +// feature list are writable; throttle limits and the API key version are +// set by the service. +func (m *Mock) UpdateAccount(_ context.Context, ops []driver.PatchOperation) (*driver.Account, error) { + m.regionMu.Lock() + defer m.regionMu.Unlock() + + next := copyAccount(&m.account) + + for _, op := range ops { + if err := applyAccountPatch(&next, op); err != nil { + return nil, err + } + } + + m.account = next + out := copyAccount(&m.account) + + return &out, nil +} + +func applyAccountPatch(acct *driver.Account, op driver.PatchOperation) error { + switch op.Path { + case pathCloudWatchRoleARN: + role := patchRef(op) + if role != "" && !roleARNPattern.MatchString(role) { + return cerrors.Newf(cerrors.InvalidArgument, "The role ARN is not well formed: %s", role) + } + + acct.CloudWatchRoleARN = role + case pathFeatures: + if op.Value != featureUsagePlans { + return cerrors.Newf(cerrors.InvalidArgument, "Invalid feature '%s'. Must be one of: [%s]", op.Value, featureUsagePlans) + } + + switch op.Op { + case opAdd: + acct.Features = patchStringSlice(acct.Features, opAdd, op.Value) + case opRemove: + acct.Features = patchStringSlice(acct.Features, opRemove, op.Value) + default: + return invalidPatchPath(op, pathCloudWatchRoleARN) + } + default: + return invalidPatchPath(op, pathCloudWatchRoleARN, pathFeatures) + } + + return nil +} + +func copyAccount(a *driver.Account) driver.Account { + out := *a + out.Features = append([]string{}, a.Features...) + + return out +} diff --git a/providers/aws/apigateway/account_test.go b/providers/aws/apigateway/account_test.go new file mode 100644 index 000000000..30e4f7bbb --- /dev/null +++ b/providers/aws/apigateway/account_test.go @@ -0,0 +1,135 @@ +package apigateway_test + +import ( + "testing" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/services/apigateway/driver" +) + +func TestGetAccountDefaults(t *testing.T) { + m := newMock(t) + + acct, err := m.GetAccount(ctx()) + if err != nil { + t.Fatalf("GetAccount: %v", err) + } + + if acct.CloudWatchRoleARN != "" || acct.Throttle.BurstLimit != 5000 || acct.Throttle.RateLimit != 10000 { + t.Fatalf("defaults = %+v", acct) + } + + if len(acct.Features) != 1 || acct.Features[0] != "UsagePlans" || acct.APIKeyVersion != "4" { + t.Fatalf("defaults = %+v", acct) + } +} + +func TestUpdateAccount(t *testing.T) { + m := newMock(t) + role := "arn:aws:iam::000000000000:role/apigw-logs" + + acct, err := m.UpdateAccount(ctx(), []driver.PatchOperation{{Op: "replace", Path: "/cloudwatchRoleArn", Value: role}}) + if err != nil || acct.CloudWatchRoleARN != role { + t.Fatalf("set role = %+v, %v", acct, err) + } + + if got, _ := m.GetAccount(ctx()); got.CloudWatchRoleARN != role { + t.Fatalf("role not persisted: %+v", got) + } + + _, err = m.UpdateAccount(ctx(), []driver.PatchOperation{{Op: "replace", Path: "/cloudwatchRoleArn", Value: "not-an-arn"}}) + if !errors.IsInvalidArgument(err) { + t.Fatalf("bad role ARN = %v, want BadRequest", err) + } + + acct, err = m.UpdateAccount(ctx(), []driver.PatchOperation{{Op: "remove", Path: "/features", Value: "UsagePlans"}}) + if err != nil || len(acct.Features) != 0 { + t.Fatalf("remove feature = %+v, %v", acct, err) + } + + acct, err = m.UpdateAccount(ctx(), []driver.PatchOperation{{Op: "add", Path: "/features", Value: "UsagePlans"}}) + if err != nil || len(acct.Features) != 1 { + t.Fatalf("add feature = %+v, %v", acct, err) + } + + _, err = m.UpdateAccount(ctx(), []driver.PatchOperation{{Op: "add", Path: "/features", Value: "Bogus"}}) + if !errors.IsInvalidArgument(err) { + t.Fatalf("unknown feature = %v, want BadRequest", err) + } + + _, err = m.UpdateAccount(ctx(), []driver.PatchOperation{{Op: "replace", Path: "/throttle/rateLimit", Value: "1"}}) + if !errors.IsInvalidArgument(err) { + t.Fatalf("read-only path = %v, want BadRequest", err) + } + + acct, err = m.UpdateAccount(ctx(), []driver.PatchOperation{{Op: "replace", Path: "/cloudwatchRoleArn", Value: ""}}) + if err != nil || acct.CloudWatchRoleARN != "" { + t.Fatalf("clear role = %+v, %v", acct, err) + } +} + +func TestSnapshotCarriesCertificatesDocumentationAndAccount(t *testing.T) { + src := newMock(t) + apiID, _, _ := deployProxyAPI(t, src, "hello", "GET", lambdaURI) + + cc, err := src.GenerateClientCertificate(ctx(), driver.GenerateClientCertificateInput{Description: "kept"}) + if err != nil { + t.Fatalf("GenerateClientCertificate: %v", err) + } + + part := mustPart(t, src, apiID, driver.DocumentationPartLocation{Type: "API"}, `{"description":"d"}`) + + if _, err := src.CreateDocumentationVersion(ctx(), apiID, driver.CreateDocumentationVersionInput{ + Version: "1", StageName: "prod", + }); err != nil { + t.Fatalf("CreateDocumentationVersion: %v", err) + } + + if _, err := src.UpdateStage(ctx(), apiID, "prod", []driver.PatchOperation{ + {Op: "replace", Path: "/clientCertificateId", Value: cc.ID}, + }); err != nil { + t.Fatalf("UpdateStage: %v", err) + } + + role := "arn:aws:iam::000000000000:role/r" + if _, err := src.UpdateAccount(ctx(), []driver.PatchOperation{{Op: "replace", Path: "/cloudwatchRoleArn", Value: role}}); err != nil { + t.Fatalf("UpdateAccount: %v", err) + } + + data, err := src.Snapshot(ctx(), false) + if err != nil { + t.Fatalf("Snapshot: %v", err) + } + + dst := newMock(t) + if err := dst.Restore(ctx(), data); err != nil { + t.Fatalf("Restore: %v", err) + } + + got, err := dst.GetClientCertificate(ctx(), cc.ID) + if err != nil || got.PEMEncodedCertificate != cc.PEMEncodedCertificate || got.Description != "kept" { + t.Fatalf("restored certificate = %+v, %v", got, err) + } + + if _, err := dst.GetDocumentationPart(ctx(), apiID, part.ID); err != nil { + t.Fatalf("restored part: %v", err) + } + + if _, err := dst.GetDocumentationVersion(ctx(), apiID, "1"); err != nil { + t.Fatalf("restored version: %v", err) + } + + st, _ := dst.GetStage(ctx(), apiID, "prod") + if st.ClientCertificateID != cc.ID || st.DocumentationVersion != "1" { + t.Fatalf("restored stage = %+v", st) + } + + if acct, _ := dst.GetAccount(ctx()); acct.CloudWatchRoleARN != role { + t.Fatalf("restored account = %+v", acct) + } + + // The restored stage still pins the certificate. + if err := dst.DeleteClientCertificate(ctx(), cc.ID); !errors.IsInvalidArgument(err) { + t.Fatalf("delete of restored in-use certificate = %v, want BadRequest", err) + } +} diff --git a/providers/aws/apigateway/apigateway.go b/providers/aws/apigateway/apigateway.go index 4bb1e0767..02b6013c8 100644 --- a/providers/aws/apigateway/apigateway.go +++ b/providers/aws/apigateway/apigateway.go @@ -59,6 +59,8 @@ type apiData struct { deployments map[string]*driver.Deployment trees map[string]map[string]*driver.Resource stages map[string]*driver.Stage + docParts map[string]*driver.DocumentationPart + docVersions map[string]*docVersion } // Mock is an in-memory implementation of Amazon API Gateway. @@ -74,11 +76,21 @@ type Mock struct { // monitoring, when wired via SetMonitoring, receives the AWS/ApiGateway // request metrics real API Gateway publishes for data-plane traffic. monitoring mondriver.Monitoring + + // regionMu guards the region-scoped resources that live outside any REST + // API: client certificates and the account settings. Lock order is regionMu + // before any apiData.mu, so a certificate delete can scan stages safely. + regionMu sync.RWMutex + certs map[string]*driver.ClientCertificate + account driver.Account } // New creates a new API Gateway mock. func New(opts *config.Options) *Mock { - return &Mock{apis: memstore.New[*apiData](), opts: opts} + return &Mock{ + apis: memstore.New[*apiData](), opts: opts, + certs: map[string]*driver.ClientCertificate{}, account: defaultAccount(), + } } // SetLambdaInvoker wires the Lambda backend so an AWS_PROXY integration invokes @@ -88,8 +100,11 @@ func (m *Mock) SetLambdaInvoker(i LambdaInvoker) { m.lambda = i } func (m *Mock) now() int64 { return m.opts.Clock.Now().UTC().Unix() } // genID returns a random 10-character lowercase-alphanumeric id. -func genID() string { - b := make([]byte, idLen) +func genID() string { return randomID(idLen) } + +// randomID returns n random lowercase-alphanumeric characters. +func randomID(n int) string { + b := make([]byte, n) _, _ = rand.Read(b) for i := range b { @@ -141,6 +156,8 @@ func (m *Mock) CreateRestAPI(_ context.Context, in *driver.CreateRestAPIInput) ( deployments: map[string]*driver.Deployment{}, trees: map[string]map[string]*driver.Resource{}, stages: map[string]*driver.Stage{}, + docParts: map[string]*driver.DocumentationPart{}, + docVersions: map[string]*docVersion{}, }) out := copyAPI(&api) diff --git a/providers/aws/apigateway/client_certificate.go b/providers/aws/apigateway/client_certificate.go new file mode 100644 index 000000000..915c03f8c --- /dev/null +++ b/providers/aws/apigateway/client_certificate.go @@ -0,0 +1,218 @@ +package apigateway + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "sort" + "strings" + "time" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/services/apigateway/driver" +) + +const ( + msgCertNotFound = "Invalid Client Certificate identifier specified" + + // certValidity is how long an API Gateway generated certificate is valid. + certValidity = 365 * 24 * time.Hour + + certKeyBits = 2048 + certSerialBits = 128 +) + +// GenerateClientCertificate creates a self-signed RSA certificate. Only the +// public certificate is kept, as API Gateway never exposes the private key. +func (m *Mock) GenerateClientCertificate( + _ context.Context, in driver.GenerateClientCertificateInput, +) (*driver.ClientCertificate, error) { + id := genShortID() + notBefore := m.opts.Clock.Now().UTC().Truncate(time.Second) + notAfter := notBefore.Add(certValidity) + + pemCert, err := selfSignedPEM(id, notBefore, notAfter) + if err != nil { + return nil, cerrors.Newf(cerrors.Internal, "generate client certificate: %v", err) + } + + cc := &driver.ClientCertificate{ + ID: id, Description: in.Description, PEMEncodedCertificate: pemCert, + CreatedDate: notBefore.Unix(), ExpirationDate: notAfter.Unix(), Tags: copyStrMap(in.Tags), + } + + m.regionMu.Lock() + m.certs[id] = cc + m.regionMu.Unlock() + + out := copyCert(cc) + + return &out, nil +} + +// selfSignedPEM issues a certificate whose subject names the certificate id, +// signed by its own fresh key. +func selfSignedPEM(id string, notBefore, notAfter time.Time) (string, error) { + key, err := rsa.GenerateKey(rand.Reader, certKeyBits) + if err != nil { + return "", err + } + + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), certSerialBits)) + if err != nil { + return "", err + } + + name := pkix.Name{ + Country: []string{"US"}, Province: []string{"Washington"}, Locality: []string{"Seattle"}, + Organization: []string{"Amazon.com"}, OrganizationalUnit: []string{"ApiGateway"}, CommonName: id, + } + tmpl := &x509.Certificate{ + SerialNumber: serial, Subject: name, Issuer: name, + NotBefore: notBefore, NotAfter: notAfter, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + } + + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + return "", err + } + + return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})), nil +} + +// GetClientCertificate returns one certificate. +func (m *Mock) GetClientCertificate(_ context.Context, id string) (*driver.ClientCertificate, error) { + m.regionMu.RLock() + defer m.regionMu.RUnlock() + + cc, ok := m.certs[id] + if !ok { + return nil, cerrors.New(cerrors.NotFound, msgCertNotFound) + } + + out := copyCert(cc) + + return &out, nil +} + +// GetClientCertificates lists certificates oldest first, one page at a time. +func (m *Mock) GetClientCertificates(_ context.Context, page driver.PageInput) (*driver.ClientCertificatePage, error) { + m.regionMu.RLock() + + all := make([]driver.ClientCertificate, 0, len(m.certs)) + for _, cc := range m.certs { + all = append(all, copyCert(cc)) + } + + m.regionMu.RUnlock() + + sort.Slice(all, func(i, j int) bool { + if all[i].CreatedDate != all[j].CreatedDate { + return all[i].CreatedDate < all[j].CreatedDate + } + + return all[i].ID < all[j].ID + }) + + items, next, err := pageOf(all, page) + if err != nil { + return nil, err + } + + return &driver.ClientCertificatePage{Items: items, Position: next}, nil +} + +// UpdateClientCertificate applies a patch document; only /description can +// change. +func (m *Mock) UpdateClientCertificate( + _ context.Context, id string, ops []driver.PatchOperation, +) (*driver.ClientCertificate, error) { + m.regionMu.Lock() + defer m.regionMu.Unlock() + + cc, ok := m.certs[id] + if !ok { + return nil, cerrors.New(cerrors.NotFound, msgCertNotFound) + } + + desc := cc.Description + + for _, op := range ops { + if op.Path != pathDescription { + return nil, invalidPatchPath(op, pathDescription) + } + + desc = op.Value + if op.Op == opRemove { + desc = "" + } + } + + cc.Description = desc + out := copyCert(cc) + + return &out, nil +} + +// DeleteClientCertificate removes a certificate that no stage references. The +// scan holds regionMu for writing, so no stage can attach it mid-delete. +func (m *Mock) DeleteClientCertificate(_ context.Context, id string) error { + m.regionMu.Lock() + defer m.regionMu.Unlock() + + if _, ok := m.certs[id]; !ok { + return cerrors.New(cerrors.NotFound, msgCertNotFound) + } + + if users := m.stagesUsingCert(id); len(users) > 0 { + return cerrors.Newf(cerrors.InvalidArgument, + "Cannot delete client certificate %s because it is in use by stage(s): %s", id, strings.Join(users, ", ")) + } + + delete(m.certs, id) + + return nil +} + +// stagesUsingCert lists "apiId/stage" for every stage pointing at the +// certificate. The caller holds regionMu. +func (m *Mock) stagesUsingCert(id string) []string { + var users []string + + for apiID, ad := range m.apis.All() { + ad.mu.RLock() + + for name, st := range ad.stages { + if st.ClientCertificateID == id { + users = append(users, fmt.Sprintf("%s/%s", apiID, name)) + } + } + + ad.mu.RUnlock() + } + + sort.Strings(users) + + return users +} + +// invalidPatchPath is the BadRequest API Gateway returns for a patch op on a +// path the resource does not allow. +func invalidPatchPath(op driver.PatchOperation, allowed ...string) error { + return cerrors.Newf(cerrors.InvalidArgument, + "Invalid patch path '%s' specified for op '%s'. Must be one of: [%s]", op.Path, op.Op, strings.Join(allowed, ", ")) +} + +func copyCert(cc *driver.ClientCertificate) driver.ClientCertificate { + out := *cc + out.Tags = copyStrMap(cc.Tags) + + return out +} diff --git a/providers/aws/apigateway/client_certificate_test.go b/providers/aws/apigateway/client_certificate_test.go new file mode 100644 index 000000000..ba307e49a --- /dev/null +++ b/providers/aws/apigateway/client_certificate_test.go @@ -0,0 +1,203 @@ +package apigateway_test + +import ( + "crypto/x509" + "encoding/pem" + "testing" + "time" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/services/apigateway/driver" +) + +func TestGenerateClientCertificateIssuesSelfSignedPEM(t *testing.T) { + m := newMock(t) + + cc, err := m.GenerateClientCertificate(ctx(), driver.GenerateClientCertificateInput{ + Description: "backend", Tags: map[string]string{"env": "dev"}, + }) + if err != nil { + t.Fatalf("GenerateClientCertificate: %v", err) + } + + if len(cc.ID) != 6 || cc.Description != "backend" || cc.Tags["env"] != "dev" { + t.Fatalf("unexpected certificate: %+v", cc) + } + + block, _ := pem.Decode([]byte(cc.PEMEncodedCertificate)) + if block == nil || block.Type != "CERTIFICATE" { + t.Fatalf("pemEncodedCertificate is not a PEM certificate: %q", cc.PEMEncodedCertificate) + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatalf("ParseCertificate: %v", err) + } + + if err := cert.CheckSignature(cert.SignatureAlgorithm, cert.RawTBSCertificate, cert.Signature); err != nil { + t.Fatalf("certificate is not self-signed: %v", err) + } + + if cc.ExpirationDate != cc.CreatedDate+int64(365*24*time.Hour/time.Second) { + t.Fatalf("expirationDate %d, createdDate %d: want a 365-day validity", cc.ExpirationDate, cc.CreatedDate) + } + + if cert.NotAfter.Unix() != cc.ExpirationDate { + t.Fatalf("cert NotAfter %v does not match expirationDate %d", cert.NotAfter, cc.ExpirationDate) + } +} + +func TestClientCertificateLifecycle(t *testing.T) { + m := newMock(t) + + cc, err := m.GenerateClientCertificate(ctx(), driver.GenerateClientCertificateInput{Description: "one"}) + if err != nil { + t.Fatalf("GenerateClientCertificate: %v", err) + } + + got, err := m.GetClientCertificate(ctx(), cc.ID) + if err != nil || got.PEMEncodedCertificate != cc.PEMEncodedCertificate { + t.Fatalf("GetClientCertificate = %+v, %v", got, err) + } + + upd, err := m.UpdateClientCertificate(ctx(), cc.ID, []driver.PatchOperation{ + {Op: "replace", Path: "/description", Value: "two"}, + }) + if err != nil || upd.Description != "two" { + t.Fatalf("UpdateClientCertificate = %+v, %v", upd, err) + } + + _, err = m.UpdateClientCertificate(ctx(), cc.ID, []driver.PatchOperation{ + {Op: "replace", Path: "/pemEncodedCertificate", Value: "x"}, + }) + if !errors.IsInvalidArgument(err) { + t.Fatalf("patching a read-only path = %v, want BadRequest", err) + } + + if err := m.DeleteClientCertificate(ctx(), cc.ID); err != nil { + t.Fatalf("DeleteClientCertificate: %v", err) + } + + _, err = m.GetClientCertificate(ctx(), cc.ID) + assertMessage(t, err, errors.IsNotFound, "Invalid Client Certificate identifier specified") + + assertMessage(t, m.DeleteClientCertificate(ctx(), cc.ID), errors.IsNotFound, + "Invalid Client Certificate identifier specified") +} + +func TestGetClientCertificatesPages(t *testing.T) { + m := newMock(t) + + for range 3 { + if _, err := m.GenerateClientCertificate(ctx(), driver.GenerateClientCertificateInput{}); err != nil { + t.Fatalf("GenerateClientCertificate: %v", err) + } + } + + first, err := m.GetClientCertificates(ctx(), driver.PageInput{Limit: 2}) + if err != nil || len(first.Items) != 2 || first.Position == "" { + t.Fatalf("first page = %+v, %v", first, err) + } + + second, err := m.GetClientCertificates(ctx(), driver.PageInput{Limit: 2, Position: first.Position}) + if err != nil || len(second.Items) != 1 || second.Position != "" { + t.Fatalf("second page = %+v, %v", second, err) + } + + if second.Items[0].ID == first.Items[0].ID || second.Items[0].ID == first.Items[1].ID { + t.Fatalf("pages overlap: %+v / %+v", first.Items, second.Items) + } + + if _, err := m.GetClientCertificates(ctx(), driver.PageInput{Limit: 501}); !errors.IsInvalidArgument(err) { + t.Fatalf("limit 501 = %v, want BadRequest", err) + } +} + +func TestStageClientCertificateReference(t *testing.T) { + m := newMock(t) + apiID, _, _ := deployProxyAPI(t, m, "hello", "GET", lambdaURI) + + _, err := m.UpdateStage(ctx(), apiID, "prod", []driver.PatchOperation{ + {Op: "replace", Path: "/clientCertificateId", Value: "nosuch"}, + }) + assertMessage(t, err, errors.IsNotFound, "Invalid Client Certificate identifier specified") + + cc, err := m.GenerateClientCertificate(ctx(), driver.GenerateClientCertificateInput{}) + if err != nil { + t.Fatalf("GenerateClientCertificate: %v", err) + } + + st, err := m.UpdateStage(ctx(), apiID, "prod", []driver.PatchOperation{ + {Op: "replace", Path: "/clientCertificateId", Value: cc.ID}, + }) + if err != nil || st.ClientCertificateID != cc.ID { + t.Fatalf("attach certificate = %+v, %v", st, err) + } + + err = m.DeleteClientCertificate(ctx(), cc.ID) + if !errors.IsInvalidArgument(err) { + t.Fatalf("deleting an in-use certificate = %v, want BadRequest", err) + } + + st, err = m.UpdateStage(ctx(), apiID, "prod", []driver.PatchOperation{ + {Op: "replace", Path: "/clientCertificateId", Value: ""}, + }) + if err != nil || st.ClientCertificateID != "" { + t.Fatalf("detach certificate = %+v, %v", st, err) + } + + if err := m.DeleteClientCertificate(ctx(), cc.ID); err != nil { + t.Fatalf("delete after detach: %v", err) + } +} + +func TestTagsOnClientCertificateAndRestAPI(t *testing.T) { + m := newMock(t) + + cc, err := m.GenerateClientCertificate(ctx(), driver.GenerateClientCertificateInput{Tags: map[string]string{"a": "1"}}) + if err != nil { + t.Fatalf("GenerateClientCertificate: %v", err) + } + + certARN := "arn:aws:apigateway:us-east-1::/clientcertificates/" + cc.ID + + if err := m.TagResource(ctx(), certARN, map[string]string{"b": "2"}); err != nil { + t.Fatalf("TagResource: %v", err) + } + + if err := m.UntagResource(ctx(), certARN, []string{"a"}); err != nil { + t.Fatalf("UntagResource: %v", err) + } + + tags, err := m.GetTags(ctx(), certARN) + if err != nil || len(tags) != 1 || tags["b"] != "2" { + t.Fatalf("GetTags = %v, %v", tags, err) + } + + got, _ := m.GetClientCertificate(ctx(), cc.ID) + if len(got.Tags) != 1 || got.Tags["b"] != "2" { + t.Fatalf("certificate tags = %v", got.Tags) + } + + api, err := m.CreateRestAPI(ctx(), &driver.CreateRestAPIInput{Name: "t"}) + if err != nil { + t.Fatalf("CreateRestAPI: %v", err) + } + + apiARN := "arn:aws:apigateway:us-east-1::/restapis/" + api.ID + if err := m.TagResource(ctx(), apiARN, map[string]string{"team": "x"}); err != nil { + t.Fatalf("TagResource api: %v", err) + } + + if a, _ := m.GetRestAPI(ctx(), api.ID); a.Tags["team"] != "x" { + t.Fatalf("rest api tags = %v", a.Tags) + } + + if _, err := m.GetTags(ctx(), "arn:aws:apigateway:us-east-1::/clientcertificates/nosuch"); !errors.IsNotFound(err) { + t.Fatalf("GetTags on a missing certificate = %v, want NotFound", err) + } + + if err := m.TagResource(ctx(), "not-an-arn", map[string]string{"k": "v"}); !errors.IsInvalidArgument(err) { + t.Fatalf("TagResource bad ARN = %v, want BadRequest", err) + } +} diff --git a/providers/aws/apigateway/deploy.go b/providers/aws/apigateway/deploy.go index 6f96136cf..37e001f2e 100644 --- a/providers/aws/apigateway/deploy.go +++ b/providers/aws/apigateway/deploy.go @@ -214,9 +214,14 @@ func (m *Mock) CreateStage(_ context.Context, restAPIID string, in driver.Create return nil, cerrors.New(cerrors.AlreadyExists, msgStageExists) } + if _, ok := ad.docVersions[in.DocumentationVersion]; in.DocumentationVersion != "" && !ok { + return nil, cerrors.New(cerrors.NotFound, msgDocVersionNotFound) + } + st := &driver.Stage{ StageName: in.StageName, RestAPIID: restAPIID, DeploymentID: in.DeploymentID, Description: in.Description, CreatedDate: m.now(), Variables: copyStrMap(in.Variables), + DocumentationVersion: in.DocumentationVersion, } ad.stages[in.StageName] = st diff --git a/providers/aws/apigateway/documentation.go b/providers/aws/apigateway/documentation.go new file mode 100644 index 000000000..9f642393a --- /dev/null +++ b/providers/aws/apigateway/documentation.go @@ -0,0 +1,388 @@ +package apigateway + +import ( + "context" + "encoding/json" + "fmt" + "regexp" + "sort" + "strings" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/services/apigateway/driver" +) + +const ( + msgDocPartNotFound = "Invalid Documentation part identifier specified" + msgDocPartExists = "Documentation part already exists for the specified location: %s." + + // wildcard is the default method and statusCode of a location: any value. + wildcard = "*" + + locationDocumented = "DOCUMENTED" + locationUndocumented = "UNDOCUMENTED" + + pathProperties = "/properties" +) + +// statusCodePattern is the DocumentationPartLocationStatusCode constraint. +var statusCodePattern = regexp.MustCompile(`^([1-5]\d\d|\*|\s*)$`) + +// docLocationRule is which optional location fields a documentation type +// accepts, and whether it needs a name. +type docLocationRule struct { + path, method, statusCode, name, nameRequired bool +} + +// locationRule returns the rule for a documentation type, per the API Gateway +// "valid location fields" table. +func locationRule(docType string) (docLocationRule, bool) { + switch docType { + case driver.DocTypeAPI: + return docLocationRule{}, true + case driver.DocTypeAuthorizer, driver.DocTypeModel: + return docLocationRule{name: true, nameRequired: true}, true + case driver.DocTypeResource: + return docLocationRule{path: true}, true + case driver.DocTypeMethod: + return docLocationRule{path: true, method: true}, true + case driver.DocTypeRequestBody: + return docLocationRule{path: true, method: true, name: true}, true + case driver.DocTypePathParameter, driver.DocTypeQueryParameter, driver.DocTypeRequestHeader: + return docLocationRule{path: true, method: true, name: true, nameRequired: true}, true + case driver.DocTypeResponse, driver.DocTypeResponseBody: + return docLocationRule{path: true, method: true, statusCode: true}, true + case driver.DocTypeResponseHeader: + return docLocationRule{path: true, method: true, statusCode: true, name: true, nameRequired: true}, true + default: + return docLocationRule{}, false + } +} + +// docTypes is the DocumentationPartType enum in model order. +func docTypes() []string { + return []string{ + driver.DocTypeAPI, driver.DocTypeAuthorizer, driver.DocTypeModel, driver.DocTypeResource, + driver.DocTypeMethod, driver.DocTypePathParameter, driver.DocTypeQueryParameter, + driver.DocTypeRequestHeader, driver.DocTypeRequestBody, driver.DocTypeResponse, + driver.DocTypeResponseHeader, driver.DocTypeResponseBody, + } +} + +func enumError(field, value string) error { + return cerrors.Newf(cerrors.InvalidArgument, + "1 validation error detected: Value '%s' at '%s' failed to satisfy constraint: "+ + "Member must satisfy enum value set: [%s]", value, field, strings.Join(docTypes(), ", ")) +} + +func nullError(field string) error { + return cerrors.Newf(cerrors.InvalidArgument, + "1 validation error detected: Value null at '%s' failed to satisfy constraint: Member must not be null", field) +} + +// canonicalLocation validates loc against its type and fills the defaults a +// real read returns: path "/", method and statusCode "*". +func canonicalLocation(in *driver.DocumentationPartLocation) (driver.DocumentationPartLocation, error) { + loc := *in + if loc.Type == "" { + return loc, nullError("createDocumentationPartInput.location.type") + } + + rule, ok := locationRule(loc.Type) + if !ok { + return loc, enumError("createDocumentationPartInput.location.type", loc.Type) + } + + if err := checkLocationFields(&loc, rule); err != nil { + return loc, err + } + + fillDefault(&loc.Path, rule.path, "/") + fillDefault(&loc.Method, rule.method, wildcard) + fillDefault(&loc.StatusCode, rule.statusCode, wildcard) + + return loc, nil +} + +// checkLocationFields rejects a field the type does not accept, a missing +// required name, a relative path and a malformed status code. +func checkLocationFields(loc *driver.DocumentationPartLocation, rule docLocationRule) error { + for _, f := range []struct { + name string + set bool + valid bool + }{ + {"path", loc.Path != "", rule.path}, + {"method", loc.Method != "", rule.method}, + {"statusCode", loc.StatusCode != "", rule.statusCode}, + {"name", loc.Name != "", rule.name}, + } { + if f.set && !f.valid { + return cerrors.Newf(cerrors.InvalidArgument, + "Invalid documentation part location: '%s' is not a valid field for type '%s'", f.name, loc.Type) + } + } + + if rule.nameRequired && loc.Name == "" { + return cerrors.Newf(cerrors.InvalidArgument, + "Invalid documentation part location: 'name' is required for type '%s'", loc.Type) + } + + if loc.Path != "" && !strings.HasPrefix(loc.Path, "/") { + return cerrors.Newf(cerrors.InvalidArgument, "Invalid documentation part location: invalid path '%s'", loc.Path) + } + + if !statusCodePattern.MatchString(loc.StatusCode) { + return cerrors.Newf(cerrors.InvalidArgument, + "Invalid documentation part location: invalid statusCode '%s'", loc.StatusCode) + } + + return nil +} + +func fillDefault(field *string, applies bool, def string) { + if applies && strings.TrimSpace(*field) == "" { + *field = def + } +} + +// describeLocation renders a location the way the duplicate-location error +// names it: type 'METHOD', path '/pets', method 'GET'. +func describeLocation(loc *driver.DocumentationPartLocation) string { + parts := []string{fmt.Sprintf("type '%s'", loc.Type)} + + for _, f := range [][2]string{ + {"path", loc.Path}, {"method", loc.Method}, {"statusCode", loc.StatusCode}, {"name", loc.Name}, + } { + if f[1] != "" { + parts = append(parts, fmt.Sprintf("%s '%s'", f[0], f[1])) + } + } + + return strings.Join(parts, ", ") +} + +// validateProperties checks properties is present and valid JSON. +func validateProperties(props, field string) error { + if props == "" { + return nullError(field) + } + + if !json.Valid([]byte(props)) { + return cerrors.New(cerrors.InvalidArgument, "Invalid documentation part properties: must be valid JSON") + } + + return nil +} + +// isDocumented reports whether a part carries any content: an empty object or +// null counts as undocumented. +func isDocumented(props string) bool { + var v any + if err := json.Unmarshal([]byte(props), &v); err != nil || v == nil { + return false + } + + if obj, ok := v.(map[string]any); ok { + return len(obj) > 0 + } + + return true +} + +// partAt returns the part stored at a canonical location, if any. The caller +// holds ad.mu. +func (ad *apiData) partAt(loc *driver.DocumentationPartLocation) *driver.DocumentationPart { + for _, p := range ad.docParts { + if p.Location == *loc { + return p + } + } + + return nil +} + +// CreateDocumentationPart adds a part at a location no other part occupies. +func (m *Mock) CreateDocumentationPart( + _ context.Context, restAPIID string, in *driver.CreateDocumentationPartInput, +) (*driver.DocumentationPart, error) { + loc, err := canonicalLocation(&in.Location) + if err != nil { + return nil, err + } + + if propErr := validateProperties(in.Properties, "createDocumentationPartInput.properties"); propErr != nil { + return nil, propErr + } + + ad, err := m.getAPI(restAPIID) + if err != nil { + return nil, err + } + + ad.mu.Lock() + defer ad.mu.Unlock() + + if ad.partAt(&loc) != nil { + return nil, cerrors.Newf(cerrors.AlreadyExists, msgDocPartExists, describeLocation(&loc)) + } + + p := &driver.DocumentationPart{ID: genShortID(), Location: loc, Properties: in.Properties} + ad.docParts[p.ID] = p + + out := *p + + return &out, nil +} + +// GetDocumentationPart returns one part. +func (m *Mock) GetDocumentationPart(_ context.Context, restAPIID, partID string) (*driver.DocumentationPart, error) { + ad, err := m.getAPI(restAPIID) + if err != nil { + return nil, err + } + + ad.mu.RLock() + defer ad.mu.RUnlock() + + p, ok := ad.docParts[partID] + if !ok { + return nil, cerrors.New(cerrors.NotFound, msgDocPartNotFound) + } + + out := *p + + return &out, nil +} + +// GetDocumentationParts lists the parts matching the type, path, name and +// location-status filters, one page at a time. +func (m *Mock) GetDocumentationParts( + _ context.Context, restAPIID string, in *driver.GetDocumentationPartsInput, +) (*driver.DocumentationPartPage, error) { + if err := validatePartFilters(in); err != nil { + return nil, err + } + + ad, err := m.getAPI(restAPIID) + if err != nil { + return nil, err + } + + ad.mu.RLock() + + all := make([]driver.DocumentationPart, 0, len(ad.docParts)) + + for _, p := range ad.docParts { + if partMatches(p, in) { + all = append(all, *p) + } + } + + ad.mu.RUnlock() + + sort.Slice(all, func(i, j int) bool { return all[i].ID < all[j].ID }) + + items, next, err := pageOf(all, in.PageInput) + if err != nil { + return nil, err + } + + return &driver.DocumentationPartPage{Items: items, Position: next}, nil +} + +func validatePartFilters(in *driver.GetDocumentationPartsInput) error { + if _, ok := locationRule(in.Type); in.Type != "" && !ok { + return enumError("type", in.Type) + } + + switch in.LocationStatus { + case "", locationDocumented, locationUndocumented: + return nil + default: + return cerrors.Newf(cerrors.InvalidArgument, + "1 validation error detected: Value '%s' at 'locationStatus' failed to satisfy constraint: "+ + "Member must satisfy enum value set: [DOCUMENTED, UNDOCUMENTED]", in.LocationStatus) + } +} + +func partMatches(p *driver.DocumentationPart, in *driver.GetDocumentationPartsInput) bool { + if in.Type != "" && p.Location.Type != in.Type { + return false + } + + if in.Path != "" && p.Location.Path != in.Path { + return false + } + + if in.NameQuery != "" && !strings.Contains(strings.ToLower(p.Location.Name), strings.ToLower(in.NameQuery)) { + return false + } + + switch in.LocationStatus { + case locationDocumented: + return isDocumented(p.Properties) + case locationUndocumented: + return !isDocumented(p.Properties) + default: + return true + } +} + +// UpdateDocumentationPart applies a patch document; only /properties can +// change, since the location is the part's identity. +func (m *Mock) UpdateDocumentationPart( + _ context.Context, restAPIID, partID string, ops []driver.PatchOperation, +) (*driver.DocumentationPart, error) { + ad, err := m.getAPI(restAPIID) + if err != nil { + return nil, err + } + + ad.mu.Lock() + defer ad.mu.Unlock() + + p, ok := ad.docParts[partID] + if !ok { + return nil, cerrors.New(cerrors.NotFound, msgDocPartNotFound) + } + + props := p.Properties + + for _, op := range ops { + if op.Path != pathProperties || op.Op != opReplace { + return nil, invalidPatchPath(op, pathProperties) + } + + if err := validateProperties(op.Value, "updateDocumentationPartInput.properties"); err != nil { + return nil, err + } + + props = op.Value + } + + p.Properties = props + out := *p + + return &out, nil +} + +// DeleteDocumentationPart removes a part. Published documentation versions +// keep their own copy. +func (m *Mock) DeleteDocumentationPart(_ context.Context, restAPIID, partID string) error { + ad, err := m.getAPI(restAPIID) + if err != nil { + return err + } + + ad.mu.Lock() + defer ad.mu.Unlock() + + if _, ok := ad.docParts[partID]; !ok { + return cerrors.New(cerrors.NotFound, msgDocPartNotFound) + } + + delete(ad.docParts, partID) + + return nil +} diff --git a/providers/aws/apigateway/documentation_import.go b/providers/aws/apigateway/documentation_import.go new file mode 100644 index 000000000..fd5e005d5 --- /dev/null +++ b/providers/aws/apigateway/documentation_import.go @@ -0,0 +1,173 @@ +package apigateway + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "gopkg.in/yaml.v3" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/services/apigateway/driver" +) + +// Import modes of ImportDocumentationParts. +const ( + importMerge = "merge" + importOverwrite = "overwrite" +) + +// importDocument is the part of an OpenAPI/Swagger file the import reads: the +// x-amazon-apigateway-documentation extension. YAML decoding also covers JSON. +type importDocument struct { + Documentation *struct { + Parts []importPart `yaml:"documentationParts"` + } `yaml:"x-amazon-apigateway-documentation"` +} + +type importPart struct { + Location struct { + Type string `yaml:"type"` + Path string `yaml:"path"` + Method string `yaml:"method"` + StatusCode string `yaml:"statusCode"` + Name string `yaml:"name"` + } `yaml:"location"` + Properties any `yaml:"properties"` +} + +// validPart is an import entry that passed validation. +type validPart struct { + loc driver.DocumentationPartLocation + props string +} + +// ImportDocumentationParts reads the documentation parts of an OpenAPI file. +// merge updates parts at matching locations and adds the rest; overwrite +// replaces every existing part. Invalid entries become warnings, or fail the +// whole import under failOnWarnings. +func (m *Mock) ImportDocumentationParts( + _ context.Context, restAPIID string, in driver.ImportDocumentationPartsInput, +) (*driver.DocumentationPartIDs, error) { + mode, err := importMode(in.Mode) + if err != nil { + return nil, err + } + + parts, warnings, err := parseImport(in.Body) + if err != nil { + return nil, err + } + + if in.FailOnWarnings && len(warnings) > 0 { + return nil, cerrors.Newf(cerrors.InvalidArgument, "Warnings found during import:\n\t%s", strings.Join(warnings, "\n\t")) + } + + ad, err := m.getAPI(restAPIID) + if err != nil { + return nil, err + } + + ad.mu.Lock() + defer ad.mu.Unlock() + + if mode == importOverwrite { + ad.docParts = make(map[string]*driver.DocumentationPart, len(parts)) + } + + ids := make([]string, 0, len(parts)) + + for _, vp := range parts { + p := ad.partAt(&vp.loc) + if p == nil { + p = &driver.DocumentationPart{ID: genShortID(), Location: vp.loc} + ad.docParts[p.ID] = p + } + + p.Properties = vp.props + ids = append(ids, p.ID) + } + + return &driver.DocumentationPartIDs{IDs: ids, Warnings: warnings}, nil +} + +// importMode resolves the mode query parameter, defaulting to merge. +func importMode(mode string) (string, error) { + switch mode { + case "": + return importMerge, nil + case importMerge, importOverwrite: + return mode, nil + default: + return "", cerrors.Newf(cerrors.InvalidArgument, + "1 validation error detected: Value '%s' at 'mode' failed to satisfy constraint: "+ + "Member must satisfy enum value set: [merge, overwrite]", mode) + } +} + +// parseImport decodes the body and validates each part, turning a bad entry +// into a warning. A body that does not parse at all is a BadRequest. +func parseImport(body []byte) ([]validPart, []string, error) { + var doc importDocument + if err := yaml.Unmarshal(body, &doc); err != nil { + return nil, nil, cerrors.Newf(cerrors.InvalidArgument, "Invalid OpenAPI input: %v", err) + } + + if doc.Documentation == nil { + return nil, nil, nil + } + + var ( + parts []validPart + warnings []string + ) + + for i := range doc.Documentation.Parts { + vp, err := validateImportPart(&doc.Documentation.Parts[i]) + if err != nil { + warnings = append(warnings, fmt.Sprintf("Documentation part %d skipped: %s", i, cerrors.Message(err))) + continue + } + + parts = append(parts, vp) + } + + return parts, warnings, nil +} + +func validateImportPart(ip *importPart) (validPart, error) { + loc, err := canonicalLocation(&driver.DocumentationPartLocation{ + Type: ip.Location.Type, Path: ip.Location.Path, Method: ip.Location.Method, + StatusCode: ip.Location.StatusCode, Name: ip.Location.Name, + }) + if err != nil { + return validPart{}, err + } + + props, err := importProperties(ip.Properties) + if err != nil { + return validPart{}, err + } + + return validPart{loc: loc, props: props}, nil +} + +// importProperties renders an entry's properties as the JSON string a part +// stores. The file may carry them as an object or as an encoded string. +func importProperties(v any) (string, error) { + if v == nil { + return "", nullError("properties") + } + + if s, ok := v.(string); ok { + return s, validateProperties(s, "properties") + } + + raw, err := json.Marshal(v) + if err != nil { + return "", cerrors.Newf(cerrors.InvalidArgument, "Invalid documentation part properties: %v", err) + } + + return string(raw), nil +} diff --git a/providers/aws/apigateway/documentation_test.go b/providers/aws/apigateway/documentation_test.go new file mode 100644 index 000000000..c58075028 --- /dev/null +++ b/providers/aws/apigateway/documentation_test.go @@ -0,0 +1,376 @@ +package apigateway_test + +import ( + "testing" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/providers/aws/apigateway" + "github.com/stackshy/cloudemu/v2/services/apigateway/driver" +) + +func newDocAPI(t *testing.T, m *apigateway.Mock) string { + t.Helper() + + api, err := m.CreateRestAPI(ctx(), &driver.CreateRestAPIInput{Name: "docs"}) + if err != nil { + t.Fatalf("CreateRestAPI: %v", err) + } + + return api.ID +} + +func mustPart(t *testing.T, m *apigateway.Mock, apiID string, loc driver.DocumentationPartLocation, props string) *driver.DocumentationPart { + t.Helper() + + p, err := m.CreateDocumentationPart(ctx(), apiID, &driver.CreateDocumentationPartInput{Location: loc, Properties: props}) + if err != nil { + t.Fatalf("CreateDocumentationPart(%+v): %v", loc, err) + } + + return p +} + +func TestDocumentationPartLocationDefaults(t *testing.T) { + m := newMock(t) + apiID := newDocAPI(t, m) + + tests := []struct { + in driver.DocumentationPartLocation + want driver.DocumentationPartLocation + }{ + {driver.DocumentationPartLocation{Type: "API"}, driver.DocumentationPartLocation{Type: "API"}}, + {driver.DocumentationPartLocation{Type: "RESOURCE"}, driver.DocumentationPartLocation{Type: "RESOURCE", Path: "/"}}, + { + driver.DocumentationPartLocation{Type: "METHOD", Path: "/pets"}, + driver.DocumentationPartLocation{Type: "METHOD", Path: "/pets", Method: "*"}, + }, + { + driver.DocumentationPartLocation{Type: "RESPONSE", StatusCode: "200"}, + driver.DocumentationPartLocation{Type: "RESPONSE", Path: "/", Method: "*", StatusCode: "200"}, + }, + { + driver.DocumentationPartLocation{Type: "RESPONSE_HEADER", Name: "Content-Type"}, + driver.DocumentationPartLocation{Type: "RESPONSE_HEADER", Path: "/", Method: "*", StatusCode: "*", Name: "Content-Type"}, + }, + {driver.DocumentationPartLocation{Type: "MODEL", Name: "Pet"}, driver.DocumentationPartLocation{Type: "MODEL", Name: "Pet"}}, + } + + for _, tc := range tests { + p := mustPart(t, m, apiID, tc.in, `{"description":"x"}`) + if p.Location != tc.want { + t.Errorf("location %+v stored as %+v, want %+v", tc.in, p.Location, tc.want) + } + + if len(p.ID) != 6 { + t.Errorf("part id %q is not 6 characters", p.ID) + } + } +} + +func TestDocumentationPartValidation(t *testing.T) { + m := newMock(t) + apiID := newDocAPI(t, m) + + bad := []struct { + name string + loc driver.DocumentationPartLocation + props string + }{ + {"missing type", driver.DocumentationPartLocation{}, `{}`}, + {"unknown type", driver.DocumentationPartLocation{Type: "WIDGET"}, `{}`}, + {"path on API", driver.DocumentationPartLocation{Type: "API", Path: "/"}, `{}`}, + {"method on RESOURCE", driver.DocumentationPartLocation{Type: "RESOURCE", Method: "GET"}, `{}`}, + {"name on METHOD", driver.DocumentationPartLocation{Type: "METHOD", Name: "x"}, `{}`}, + {"statusCode on METHOD", driver.DocumentationPartLocation{Type: "METHOD", StatusCode: "200"}, `{}`}, + {"missing name on MODEL", driver.DocumentationPartLocation{Type: "MODEL"}, `{}`}, + {"missing name on QUERY_PARAMETER", driver.DocumentationPartLocation{Type: "QUERY_PARAMETER"}, `{}`}, + {"bad status code", driver.DocumentationPartLocation{Type: "RESPONSE", StatusCode: "700"}, `{}`}, + {"relative path", driver.DocumentationPartLocation{Type: "RESOURCE", Path: "pets"}, `{}`}, + {"missing properties", driver.DocumentationPartLocation{Type: "API"}, ``}, + {"invalid properties", driver.DocumentationPartLocation{Type: "API"}, `{not json`}, + } + + for _, tc := range bad { + _, err := m.CreateDocumentationPart(ctx(), apiID, &driver.CreateDocumentationPartInput{Location: tc.loc, Properties: tc.props}) + if !errors.IsInvalidArgument(err) { + t.Errorf("%s: got %v, want BadRequest", tc.name, err) + } + } + + _, err := m.CreateDocumentationPart(ctx(), "nosuch", &driver.CreateDocumentationPartInput{ + Location: driver.DocumentationPartLocation{Type: "API"}, Properties: `{}`, + }) + if !errors.IsNotFound(err) { + t.Fatalf("unknown REST API = %v, want NotFound", err) + } +} + +func TestDocumentationPartDuplicateLocation(t *testing.T) { + m := newMock(t) + apiID := newDocAPI(t, m) + + mustPart(t, m, apiID, driver.DocumentationPartLocation{Type: "API"}, `{"a":1}`) + + _, err := m.CreateDocumentationPart(ctx(), apiID, &driver.CreateDocumentationPartInput{ + Location: driver.DocumentationPartLocation{Type: "API"}, Properties: `{"a":2}`, + }) + assertMessage(t, err, errors.IsAlreadyExists, "Documentation part already exists for the specified location: type 'API'.") + + // An omitted field equals its default, so these two locations collide. + mustPart(t, m, apiID, driver.DocumentationPartLocation{Type: "METHOD", Path: "/pets"}, `{}`) + + _, err = m.CreateDocumentationPart(ctx(), apiID, &driver.CreateDocumentationPartInput{ + Location: driver.DocumentationPartLocation{Type: "METHOD", Path: "/pets", Method: "*"}, Properties: `{}`, + }) + assertMessage(t, err, errors.IsAlreadyExists, + "Documentation part already exists for the specified location: type 'METHOD', path '/pets', method '*'.") +} + +func TestDocumentationPartLifecycleAndFilters(t *testing.T) { + m := newMock(t) + apiID := newDocAPI(t, m) + + api := mustPart(t, m, apiID, driver.DocumentationPartLocation{Type: "API"}, `{"info":{"description":"d"}}`) + mustPart(t, m, apiID, driver.DocumentationPartLocation{Type: "METHOD", Path: "/pets", Method: "GET"}, `{"summary":"s"}`) + mustPart(t, m, apiID, driver.DocumentationPartLocation{Type: "QUERY_PARAMETER", Path: "/pets", Method: "GET", Name: "page"}, `{}`) + mustPart(t, m, apiID, driver.DocumentationPartLocation{Type: "MODEL", Name: "PetModel"}, `{"description":"m"}`) + + list := func(in *driver.GetDocumentationPartsInput) []driver.DocumentationPart { + t.Helper() + + page, err := m.GetDocumentationParts(ctx(), apiID, in) + if err != nil { + t.Fatalf("GetDocumentationParts(%+v): %v", in, err) + } + + return page.Items + } + + if n := len(list(&driver.GetDocumentationPartsInput{})); n != 4 { + t.Fatalf("all parts = %d, want 4", n) + } + + if got := list(&driver.GetDocumentationPartsInput{Type: "METHOD"}); len(got) != 1 || got[0].Location.Method != "GET" { + t.Fatalf("type=METHOD = %+v", got) + } + + if got := list(&driver.GetDocumentationPartsInput{Path: "/pets"}); len(got) != 2 { + t.Fatalf("path=/pets = %+v", got) + } + + if got := list(&driver.GetDocumentationPartsInput{NameQuery: "pet"}); len(got) != 1 || got[0].Location.Name != "PetModel" { + t.Fatalf("name=pet = %+v", got) + } + + if got := list(&driver.GetDocumentationPartsInput{LocationStatus: "UNDOCUMENTED"}); len(got) != 1 || got[0].Location.Name != "page" { + t.Fatalf("locationStatus=UNDOCUMENTED = %+v", got) + } + + if got := list(&driver.GetDocumentationPartsInput{LocationStatus: "DOCUMENTED"}); len(got) != 3 { + t.Fatalf("locationStatus=DOCUMENTED = %+v", got) + } + + if _, err := m.GetDocumentationParts(ctx(), apiID, &driver.GetDocumentationPartsInput{Type: "BOGUS"}); !errors.IsInvalidArgument(err) { + t.Fatalf("bad type filter = %v, want BadRequest", err) + } + + page, err := m.GetDocumentationParts(ctx(), apiID, &driver.GetDocumentationPartsInput{PageInput: driver.PageInput{Limit: 3}}) + if err != nil || len(page.Items) != 3 || page.Position == "" { + t.Fatalf("paged parts = %+v, %v", page, err) + } + + upd, err := m.UpdateDocumentationPart(ctx(), apiID, api.ID, []driver.PatchOperation{ + {Op: "replace", Path: "/properties", Value: `{"info":{"description":"new"}}`}, + }) + if err != nil || upd.Properties != `{"info":{"description":"new"}}` { + t.Fatalf("UpdateDocumentationPart = %+v, %v", upd, err) + } + + _, err = m.UpdateDocumentationPart(ctx(), apiID, api.ID, []driver.PatchOperation{ + {Op: "replace", Path: "/properties", Value: `{broken`}, + }) + if !errors.IsInvalidArgument(err) { + t.Fatalf("invalid properties patch = %v, want BadRequest", err) + } + + _, err = m.UpdateDocumentationPart(ctx(), apiID, api.ID, []driver.PatchOperation{ + {Op: "replace", Path: "/location/type", Value: "MODEL"}, + }) + if !errors.IsInvalidArgument(err) { + t.Fatalf("location patch = %v, want BadRequest", err) + } + + if err := m.DeleteDocumentationPart(ctx(), apiID, api.ID); err != nil { + t.Fatalf("DeleteDocumentationPart: %v", err) + } + + _, err = m.GetDocumentationPart(ctx(), apiID, api.ID) + assertMessage(t, err, errors.IsNotFound, "Invalid Documentation part identifier specified") +} + +const importDoc = `{ + "swagger": "2.0", + "info": {"title": "docs", "version": "1"}, + "paths": {}, + "x-amazon-apigateway-documentation": { + "version": "1.0.0", + "documentationParts": [ + {"location": {"type": "API"}, "properties": {"description": "imported"}}, + {"location": {"type": "METHOD", "path": "/pets", "method": "GET"}, "properties": {"summary": "list"}}, + {"location": {"type": "RESOURCE", "method": "GET"}, "properties": {"summary": "bad"}} + ] + } +}` + +func TestImportDocumentationPartsMergeAndOverwrite(t *testing.T) { + m := newMock(t) + apiID := newDocAPI(t, m) + + existingAPI := mustPart(t, m, apiID, driver.DocumentationPartLocation{Type: "API"}, `{"description":"old"}`) + keep := mustPart(t, m, apiID, driver.DocumentationPartLocation{Type: "MODEL", Name: "Pet"}, `{"description":"keep"}`) + + _, err := m.ImportDocumentationParts(ctx(), apiID, driver.ImportDocumentationPartsInput{ + Body: []byte(importDoc), FailOnWarnings: true, + }) + if !errors.IsInvalidArgument(err) { + t.Fatalf("failOnWarnings import = %v, want BadRequest", err) + } + + res, err := m.ImportDocumentationParts(ctx(), apiID, driver.ImportDocumentationPartsInput{Body: []byte(importDoc)}) + if err != nil { + t.Fatalf("merge import: %v", err) + } + + if len(res.IDs) != 2 || len(res.Warnings) != 1 { + t.Fatalf("merge import = %+v, want 2 ids and 1 warning", res) + } + + // Merge keeps the part id at a matching location and updates its content. + got, err := m.GetDocumentationPart(ctx(), apiID, existingAPI.ID) + if err != nil || got.Properties != `{"description":"imported"}` { + t.Fatalf("merged API part = %+v, %v", got, err) + } + + if _, err := m.GetDocumentationPart(ctx(), apiID, keep.ID); err != nil { + t.Fatalf("merge dropped an unrelated part: %v", err) + } + + res, err = m.ImportDocumentationParts(ctx(), apiID, driver.ImportDocumentationPartsInput{Body: []byte(importDoc), Mode: "overwrite"}) + if err != nil || len(res.IDs) != 2 { + t.Fatalf("overwrite import = %+v, %v", res, err) + } + + if _, err := m.GetDocumentationPart(ctx(), apiID, keep.ID); !errors.IsNotFound(err) { + t.Fatalf("overwrite kept a part not in the import: %v", err) + } + + page, _ := m.GetDocumentationParts(ctx(), apiID, &driver.GetDocumentationPartsInput{}) + if len(page.Items) != 2 { + t.Fatalf("parts after overwrite = %+v", page.Items) + } + + yamlDoc := "swagger: '2.0'\nx-amazon-apigateway-documentation:\n documentationParts:\n" + + " - location: {type: MODEL, name: Cat}\n properties: {description: cat}\n" + + res, err = m.ImportDocumentationParts(ctx(), apiID, driver.ImportDocumentationPartsInput{Body: []byte(yamlDoc)}) + if err != nil || len(res.IDs) != 1 { + t.Fatalf("yaml import = %+v, %v", res, err) + } + + if _, err := m.ImportDocumentationParts(ctx(), apiID, driver.ImportDocumentationPartsInput{ + Body: []byte(importDoc), Mode: "replace", + }); !errors.IsInvalidArgument(err) { + t.Fatalf("bad mode = %v, want BadRequest", err) + } + + if _, err := m.ImportDocumentationParts(ctx(), apiID, driver.ImportDocumentationPartsInput{Body: []byte("{")}); !errors.IsInvalidArgument(err) { + t.Fatalf("unparsable body = %v, want BadRequest", err) + } +} + +func TestDocumentationVersionLifecycle(t *testing.T) { + m := newMock(t) + apiID, _, _ := deployProxyAPI(t, m, "hello", "GET", lambdaURI) + + part := mustPart(t, m, apiID, driver.DocumentationPartLocation{Type: "API"}, `{"description":"v1"}`) + + _, err := m.CreateDocumentationVersion(ctx(), apiID, driver.CreateDocumentationVersionInput{}) + if !errors.IsInvalidArgument(err) { + t.Fatalf("missing version = %v, want BadRequest", err) + } + + _, err = m.CreateDocumentationVersion(ctx(), apiID, driver.CreateDocumentationVersionInput{Version: "1", StageName: "nosuch"}) + if !errors.IsNotFound(err) { + t.Fatalf("unknown stage = %v, want NotFound", err) + } + + v, err := m.CreateDocumentationVersion(ctx(), apiID, driver.CreateDocumentationVersionInput{ + Version: "1.0", Description: "first", StageName: "prod", + }) + if err != nil || v.Version != "1.0" || v.Description != "first" { + t.Fatalf("CreateDocumentationVersion = %+v, %v", v, err) + } + + st, _ := m.GetStage(ctx(), apiID, "prod") + if st.DocumentationVersion != "1.0" { + t.Fatalf("stage documentationVersion = %q, want 1.0", st.DocumentationVersion) + } + + _, err = m.CreateDocumentationVersion(ctx(), apiID, driver.CreateDocumentationVersionInput{Version: "1.0"}) + if !errors.IsAlreadyExists(err) { + t.Fatalf("duplicate version = %v, want Conflict", err) + } + + if _, err := m.UpdateDocumentationPart(ctx(), apiID, part.ID, []driver.PatchOperation{ + {Op: "replace", Path: "/properties", Value: `{"description":"v2"}`}, + }); err != nil { + t.Fatalf("UpdateDocumentationPart: %v", err) + } + + if _, err := m.CreateDocumentationVersion(ctx(), apiID, driver.CreateDocumentationVersionInput{Version: "2.0"}); err != nil { + t.Fatalf("second version: %v", err) + } + + page, err := m.GetDocumentationVersions(ctx(), apiID, driver.PageInput{}) + if err != nil || len(page.Items) != 2 { + t.Fatalf("GetDocumentationVersions = %+v, %v", page, err) + } + + upd, err := m.UpdateDocumentationVersion(ctx(), apiID, "2.0", []driver.PatchOperation{ + {Op: "replace", Path: "/description", Value: "second"}, + }) + if err != nil || upd.Description != "second" { + t.Fatalf("UpdateDocumentationVersion = %+v, %v", upd, err) + } + + st, err = m.UpdateStage(ctx(), apiID, "prod", []driver.PatchOperation{ + {Op: "replace", Path: "/documentationVersion", Value: "2.0"}, + }) + if err != nil || st.DocumentationVersion != "2.0" { + t.Fatalf("UpdateStage documentationVersion = %+v, %v", st, err) + } + + _, err = m.UpdateStage(ctx(), apiID, "prod", []driver.PatchOperation{ + {Op: "replace", Path: "/documentationVersion", Value: "9.9"}, + }) + assertMessage(t, err, errors.IsNotFound, "Invalid Documentation version identifier specified") + + if err := m.DeleteDocumentationVersion(ctx(), apiID, "2.0"); !errors.IsInvalidArgument(err) { + t.Fatalf("deleting a version a stage uses = %v, want BadRequest", err) + } + + if err := m.DeleteDocumentationVersion(ctx(), apiID, "1.0"); err != nil { + t.Fatalf("DeleteDocumentationVersion: %v", err) + } + + _, err = m.GetDocumentationVersion(ctx(), apiID, "1.0") + assertMessage(t, err, errors.IsNotFound, "Invalid Documentation version identifier specified") + + // CreateStage validates documentationVersion too. + _, err = m.CreateStage(ctx(), apiID, driver.CreateStageInput{ + StageName: "beta", DeploymentID: st.DeploymentID, DocumentationVersion: "nope", + }) + if !errors.IsNotFound(err) { + t.Fatalf("CreateStage with unknown documentationVersion = %v, want NotFound", err) + } +} diff --git a/providers/aws/apigateway/documentation_version.go b/providers/aws/apigateway/documentation_version.go new file mode 100644 index 000000000..2cc5511e8 --- /dev/null +++ b/providers/aws/apigateway/documentation_version.go @@ -0,0 +1,187 @@ +package apigateway + +import ( + "context" + "sort" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/services/apigateway/driver" +) + +const ( + msgDocVersionNotFound = "Invalid Documentation version identifier specified" + msgDocVersionExists = "Documentation version already exists" +) + +// docVersion is a published documentation version plus the parts it froze. +// Its fields are exported so the snapshot can serialize it directly. +type docVersion struct { + Version driver.DocumentationVersion `json:"version"` + Parts map[string]driver.DocumentationPart `json:"parts,omitempty"` +} + +// CreateDocumentationVersion freezes the API's current parts under a new +// version name, optionally associating it with a stage. +func (m *Mock) CreateDocumentationVersion( + _ context.Context, restAPIID string, in driver.CreateDocumentationVersionInput, +) (*driver.DocumentationVersion, error) { + if in.Version == "" { + return nil, nullError("createDocumentationVersionInput.documentationVersion") + } + + ad, err := m.getAPI(restAPIID) + if err != nil { + return nil, err + } + + ad.mu.Lock() + defer ad.mu.Unlock() + + if _, exists := ad.docVersions[in.Version]; exists { + return nil, cerrors.New(cerrors.AlreadyExists, msgDocVersionExists) + } + + var stage *driver.Stage + + if in.StageName != "" { + st, ok := ad.stages[in.StageName] + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "Invalid stage identifier specified %s", in.StageName) + } + + stage = st + } + + dv := &docVersion{ + Version: driver.DocumentationVersion{Version: in.Version, Description: in.Description, CreatedDate: m.now()}, + Parts: make(map[string]driver.DocumentationPart, len(ad.docParts)), + } + + for id, p := range ad.docParts { + dv.Parts[id] = *p + } + + ad.docVersions[in.Version] = dv + + if stage != nil { + stage.DocumentationVersion = in.Version + } + + out := dv.Version + + return &out, nil +} + +// GetDocumentationVersion returns one version. +func (m *Mock) GetDocumentationVersion(_ context.Context, restAPIID, version string) (*driver.DocumentationVersion, error) { + ad, err := m.getAPI(restAPIID) + if err != nil { + return nil, err + } + + ad.mu.RLock() + defer ad.mu.RUnlock() + + dv, ok := ad.docVersions[version] + if !ok { + return nil, cerrors.New(cerrors.NotFound, msgDocVersionNotFound) + } + + out := dv.Version + + return &out, nil +} + +// GetDocumentationVersions lists versions oldest first, one page at a time. +func (m *Mock) GetDocumentationVersions( + _ context.Context, restAPIID string, page driver.PageInput, +) (*driver.DocumentationVersionPage, error) { + ad, err := m.getAPI(restAPIID) + if err != nil { + return nil, err + } + + ad.mu.RLock() + + all := make([]driver.DocumentationVersion, 0, len(ad.docVersions)) + for _, dv := range ad.docVersions { + all = append(all, dv.Version) + } + + ad.mu.RUnlock() + + sort.Slice(all, func(i, j int) bool { + if all[i].CreatedDate != all[j].CreatedDate { + return all[i].CreatedDate < all[j].CreatedDate + } + + return all[i].Version < all[j].Version + }) + + items, next, err := pageOf(all, page) + if err != nil { + return nil, err + } + + return &driver.DocumentationVersionPage{Items: items, Position: next}, nil +} + +// UpdateDocumentationVersion applies a patch document; only /description can +// change. +func (m *Mock) UpdateDocumentationVersion( + _ context.Context, restAPIID, version string, ops []driver.PatchOperation, +) (*driver.DocumentationVersion, error) { + ad, err := m.getAPI(restAPIID) + if err != nil { + return nil, err + } + + ad.mu.Lock() + defer ad.mu.Unlock() + + dv, ok := ad.docVersions[version] + if !ok { + return nil, cerrors.New(cerrors.NotFound, msgDocVersionNotFound) + } + + desc := dv.Version.Description + + for _, op := range ops { + if op.Path != pathDescription { + return nil, invalidPatchPath(op, pathDescription) + } + + desc = patchRef(op) + } + + dv.Version.Description = desc + out := dv.Version + + return &out, nil +} + +// DeleteDocumentationVersion removes a version no stage is associated with. +func (m *Mock) DeleteDocumentationVersion(_ context.Context, restAPIID, version string) error { + ad, err := m.getAPI(restAPIID) + if err != nil { + return err + } + + ad.mu.Lock() + defer ad.mu.Unlock() + + if _, ok := ad.docVersions[version]; !ok { + return cerrors.New(cerrors.NotFound, msgDocVersionNotFound) + } + + for _, st := range ad.stages { + if st.DocumentationVersion == version { + return cerrors.New(cerrors.InvalidArgument, + "Cannot delete documentation version because there are API Stages associated with it.") + } + } + + delete(ad.docVersions, version) + + return nil +} diff --git a/providers/aws/apigateway/paging.go b/providers/aws/apigateway/paging.go new file mode 100644 index 000000000..7cd5a745d --- /dev/null +++ b/providers/aws/apigateway/paging.go @@ -0,0 +1,42 @@ +package apigateway + +import ( + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/pagination" + "github.com/stackshy/cloudemu/v2/services/apigateway/driver" +) + +// Page sizes for the position/limit collections: 25 when limit is omitted and +// at most 500, as API Gateway documents. +const ( + defaultPageLimit = 25 + maxPageLimit = 500 +) + +// shortIDLen is the length of client certificate and documentation part ids +// (six lowercase alphanumerics, e.g. "a1b2c3"). +const shortIDLen = 6 + +// genShortID returns a random six-character lowercase-alphanumeric id. +func genShortID() string { return randomID(shortIDLen) } + +// pageOf slices one page out of items, which the caller has already put in a +// stable order. It returns the page and the position of the next one (empty on +// the last page). +func pageOf[T any](items []T, in driver.PageInput) (page []T, next string, err error) { + limit := in.Limit + if limit == 0 { + limit = defaultPageLimit + } + + if limit < 0 || limit > maxPageLimit { + return nil, "", cerrors.Newf(cerrors.InvalidArgument, "limit must be between 1 and %d", maxPageLimit) + } + + p, err := pagination.Paginate(items, in.Position, limit) + if err != nil { + return nil, "", cerrors.New(cerrors.InvalidArgument, "Invalid position parameter") + } + + return p.Items, p.NextPageToken, nil +} diff --git a/providers/aws/apigateway/patch.go b/providers/aws/apigateway/patch.go index 2a438aa62..f8b81a3ed 100644 --- a/providers/aws/apigateway/patch.go +++ b/providers/aws/apigateway/patch.go @@ -283,6 +283,11 @@ func (m *Mock) UpdateStage( return nil, err } + // regionMu first (the documented lock order), so a certificate cannot be + // deleted between the existence check and the attach. + m.regionMu.RLock() + defer m.regionMu.RUnlock() + ad.mu.Lock() defer ad.mu.Unlock() @@ -291,20 +296,24 @@ func (m *Mock) UpdateStage( return nil, cerrors.Newf(cerrors.NotFound, "Invalid stage identifier specified %s", stageName) } + // Patch a copy so a failing op leaves the stage untouched. + next := copyStage(st) for _, op := range ops { - if err := applyStagePatch(ad, st, op); err != nil { + if err := m.applyStagePatch(ad, &next, op); err != nil { return nil, err } } + *st = next out := copyStage(st) return &out, nil } -// applyStagePatch applies one patch op to a Stage, validating a /deploymentId -// re-point against the API's deployments. -func applyStagePatch(ad *apiData, st *driver.Stage, op driver.PatchOperation) error { +// applyStagePatch applies one patch op to a Stage, validating a /deploymentId, +// /clientCertificateId or /documentationVersion reference. The caller holds +// regionMu and ad.mu. +func (m *Mock) applyStagePatch(ad *apiData, st *driver.Stage, op driver.PatchOperation) error { switch { case op.Path == pathDescription: st.Description = op.Value @@ -314,6 +323,8 @@ func applyStagePatch(ad *apiData, st *driver.Stage, op driver.PatchOperation) er } st.DeploymentID = op.Value + case op.Path == pathClientCertificateID || op.Path == pathDocumentationVersion: + return m.applyStageRefPatch(ad, st, op) case strings.HasPrefix(op.Path, "/variables/"): key := unescapePointer(strings.TrimPrefix(op.Path, "/variables/")) if op.Op == opRemove { @@ -332,6 +343,45 @@ func applyStagePatch(ad *apiData, st *driver.Stage, op driver.PatchOperation) er return nil } +// Stage patch paths that reference other resources. +const ( + pathClientCertificateID = "/clientCertificateId" + pathDocumentationVersion = "/documentationVersion" +) + +// applyStageRefPatch sets or clears the stage's client certificate or +// documentation version, which must exist. The caller holds regionMu and ad.mu. +func (m *Mock) applyStageRefPatch(ad *apiData, st *driver.Stage, op driver.PatchOperation) error { + ref := patchRef(op) + + if op.Path == pathClientCertificateID { + if _, ok := m.certs[ref]; ref != "" && !ok { + return cerrors.New(cerrors.NotFound, msgCertNotFound) + } + + st.ClientCertificateID = ref + + return nil + } + + if _, ok := ad.docVersions[ref]; ref != "" && !ok { + return cerrors.New(cerrors.NotFound, msgDocVersionNotFound) + } + + st.DocumentationVersion = ref + + return nil +} + +// patchRef is the reference id a replace/add op sets; a remove op clears it. +func patchRef(op driver.PatchOperation) string { + if op.Op == opRemove { + return "" + } + + return op.Value +} + // patchStringSlice adds or removes v from a string slice (used for the // add/remove-valued list paths such as binaryMediaTypes). func patchStringSlice(s []string, op, v string) []string { diff --git a/providers/aws/apigateway/snapshot.go b/providers/aws/apigateway/snapshot.go index cbf107774..f22cf05b8 100644 --- a/providers/aws/apigateway/snapshot.go +++ b/providers/aws/apigateway/snapshot.go @@ -16,7 +16,9 @@ var _ snapshot.Snapshottable = (*Mock)(nil) // (invisible to json.Marshal), so each API is promoted to an exported form keyed // by REST API id. The per-API lock and the wired opts are not serialized. type apigatewaySnapshot struct { - APIs map[string]*apiSnapshot `json:"apis,omitempty"` + APIs map[string]*apiSnapshot `json:"apis,omitempty"` + Certs map[string]*driver.ClientCertificate `json:"clientCertificates,omitempty"` + Account *driver.Account `json:"account,omitempty"` } // apiSnapshot is the exported form of apiData: the REST API plus its resource @@ -28,6 +30,8 @@ type apiSnapshot struct { 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"` + DocParts map[string]*driver.DocumentationPart `json:"documentationParts,omitempty"` + DocVersions map[string]*docVersion `json:"documentationVersions,omitempty"` } // Snapshot captures the mock's entire state as JSON. includeAssets is unused. API Gateway holds @@ -43,6 +47,22 @@ func (m *Mock) Snapshot(_ context.Context, _ bool) (json.RawMessage, error) { } } + m.regionMu.RLock() + + acct := copyAccount(&m.account) + snap.Account = &acct + + if len(m.certs) > 0 { + snap.Certs = make(map[string]*driver.ClientCertificate, len(m.certs)) + + for id, cc := range m.certs { + cp := copyCert(cc) + snap.Certs[id] = &cp + } + } + + m.regionMu.RUnlock() + return json.Marshal(snap) } @@ -80,6 +100,24 @@ func snapshotAPI(ad *apiData) *apiSnapshot { as.Stages[name] = &cp } + as.DocParts = make(map[string]*driver.DocumentationPart, len(ad.docParts)) + + for id, p := range ad.docParts { + cp := *p + as.DocParts[id] = &cp + } + + as.DocVersions = make(map[string]*docVersion, len(ad.docVersions)) + + for v, dv := range ad.docVersions { + cp := docVersion{Version: dv.Version, Parts: make(map[string]driver.DocumentationPart, len(dv.Parts))} + for id, p := range dv.Parts { + cp.Parts[id] = p + } + + as.DocVersions[v] = &cp + } + return as } @@ -95,6 +133,17 @@ func (m *Mock) Restore(_ context.Context, data json.RawMessage) error { m.apis.Set(id, restoreAPI(as)) } + m.regionMu.Lock() + defer m.regionMu.Unlock() + + for id, cc := range snap.Certs { + m.certs[id] = cc + } + + if snap.Account != nil { + m.account = *snap.Account + } + return nil } @@ -108,6 +157,16 @@ func restoreAPI(as *apiSnapshot) *apiData { 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)), + docParts: make(map[string]*driver.DocumentationPart, len(as.DocParts)), + docVersions: make(map[string]*docVersion, len(as.DocVersions)), + } + + for id, p := range as.DocParts { + ad.docParts[id] = p + } + + for v, dv := range as.DocVersions { + ad.docVersions[v] = dv } for rid, r := range as.Resources { diff --git a/providers/aws/apigateway/tags.go b/providers/aws/apigateway/tags.go new file mode 100644 index 000000000..342130d97 --- /dev/null +++ b/providers/aws/apigateway/tags.go @@ -0,0 +1,139 @@ +package apigateway + +import ( + "context" + "strings" + + cerrors "github.com/stackshy/cloudemu/v2/errors" +) + +// ARN resource paths the v1 tagging API addresses. +const ( + arnRestAPIs = "/restapis/" + arnClientCertificates = "/clientcertificates/" +) + +// arnScheme and arnService are the first and service fields of an API +// Gateway ARN. +const ( + arnScheme = "arn" + arnService = "apigateway" +) + +// arnFields is the field count of arn:partition:apigateway:region::/path. +const arnFields = 6 + +// tagTarget is the tag map a tagging call reads or mutates, resolved from an +// ARN. apply runs fn on the live map under the owning lock. +type tagTarget struct { + apply func(fn func(tags map[string]string) map[string]string) error +} + +// resolveTagTarget maps an API Gateway ARN to the resource whose tags it +// names: a REST API or a client certificate. +func (m *Mock) resolveTagTarget(arn string) (tagTarget, error) { + parts := strings.SplitN(arn, ":", arnFields) + if len(parts) != arnFields || parts[0] != arnScheme || parts[2] != arnService { + return tagTarget{}, cerrors.Newf(cerrors.InvalidArgument, "Invalid ARN specified in the request: %s", arn) + } + + resource := parts[5] + + switch { + case strings.HasPrefix(resource, arnRestAPIs) && !strings.Contains(strings.TrimPrefix(resource, arnRestAPIs), "/"): + return m.restAPITagTarget(strings.TrimPrefix(resource, arnRestAPIs)) + case strings.HasPrefix(resource, arnClientCertificates): + return m.certTagTarget(strings.TrimPrefix(resource, arnClientCertificates)) + default: + return tagTarget{}, cerrors.Newf(cerrors.NotFound, "Invalid resource identifier specified in the ARN: %s", arn) + } +} + +func (m *Mock) restAPITagTarget(id string) (tagTarget, error) { + ad, err := m.getAPI(id) + if err != nil { + return tagTarget{}, err + } + + return tagTarget{apply: func(fn func(map[string]string) map[string]string) error { + ad.mu.Lock() + defer ad.mu.Unlock() + + ad.api.Tags = fn(ad.api.Tags) + + return nil + }}, nil +} + +func (m *Mock) certTagTarget(id string) (tagTarget, error) { + return tagTarget{apply: func(fn func(map[string]string) map[string]string) error { + m.regionMu.Lock() + defer m.regionMu.Unlock() + + cc, ok := m.certs[id] + if !ok { + return cerrors.New(cerrors.NotFound, msgCertNotFound) + } + + cc.Tags = fn(cc.Tags) + + return nil + }}, nil +} + +// TagResource adds or overwrites tags on the resource the ARN names. +func (m *Mock) TagResource(_ context.Context, arn string, tags map[string]string) error { + target, err := m.resolveTagTarget(arn) + if err != nil { + return err + } + + return target.apply(func(cur map[string]string) map[string]string { + if cur == nil { + cur = make(map[string]string, len(tags)) + } + + for k, v := range tags { + cur[k] = v + } + + return cur + }) +} + +// UntagResource removes tag keys from the resource the ARN names. +func (m *Mock) UntagResource(_ context.Context, arn string, keys []string) error { + target, err := m.resolveTagTarget(arn) + if err != nil { + return err + } + + return target.apply(func(cur map[string]string) map[string]string { + for _, k := range keys { + delete(cur, k) + } + + return cur + }) +} + +// GetTags returns the tags of the resource the ARN names. +func (m *Mock) GetTags(_ context.Context, arn string) (map[string]string, error) { + target, err := m.resolveTagTarget(arn) + if err != nil { + return nil, err + } + + var out map[string]string + + err = target.apply(func(cur map[string]string) map[string]string { + out = copyStrMap(cur) + + return cur + }) + if out == nil && err == nil { + out = map[string]string{} + } + + return out, err +} diff --git a/server/aws/apigateway/account.go b/server/aws/apigateway/account.go new file mode 100644 index 000000000..8acd81985 --- /dev/null +++ b/server/aws/apigateway/account.go @@ -0,0 +1,53 @@ +package apigateway + +import ( + "net/http" + + "github.com/stackshy/cloudemu/v2/services/apigateway/driver" +) + +type throttleSettings struct { + BurstLimit int `json:"burstLimit"` + RateLimit float64 `json:"rateLimit"` +} + +// accountResponse is the Account wire object. +type accountResponse struct { + CloudWatchRoleARN string `json:"cloudwatchRoleArn,omitempty"` + ThrottleSettings throttleSettings `json:"throttleSettings"` + Features []string `json:"features"` + APIKeyVersion string `json:"apiKeyVersion"` +} + +func toAccountResponse(a *driver.Account) accountResponse { + return accountResponse{ + CloudWatchRoleARN: a.CloudWatchRoleARN, + ThrottleSettings: throttleSettings{BurstLimit: a.Throttle.BurstLimit, RateLimit: a.Throttle.RateLimit}, + Features: a.Features, APIKeyVersion: a.APIKeyVersion, + } +} + +// serveAccount handles /account: GET=GetAccount, PATCH=UpdateAccount. +func (h *Handler) serveAccount(w http.ResponseWriter, r *http.Request) { + if servePatch(w, r, + func(ops []driver.PatchOperation) (*driver.Account, error) { + return h.ag.UpdateAccount(r.Context(), ops) + }, + toAccountResponse, + ) { + return + } + + if r.Method != http.MethodGet { + writeMethodNotAllowed(w) + return + } + + acct, err := h.ag.GetAccount(r.Context()) + if err != nil { + writeErr(w, err) + return + } + + writeJSON(w, http.StatusOK, toAccountResponse(acct)) +} diff --git a/server/aws/apigateway/client_certificates.go b/server/aws/apigateway/client_certificates.go new file mode 100644 index 000000000..092b86eb3 --- /dev/null +++ b/server/aws/apigateway/client_certificates.go @@ -0,0 +1,165 @@ +package apigateway + +import ( + "net/http" + "strconv" + "strings" + + "github.com/stackshy/cloudemu/v2/services/apigateway/driver" +) + +// clientCertificateResponse is the ClientCertificate wire object. +type clientCertificateResponse struct { + ClientCertificateID string `json:"clientCertificateId"` + Description string `json:"description,omitempty"` + PEMEncodedCertificate string `json:"pemEncodedCertificate"` + CreatedDate int64 `json:"createdDate"` + ExpirationDate int64 `json:"expirationDate"` + Tags map[string]string `json:"tags,omitempty"` +} + +type listClientCertificatesResponse struct { + Position string `json:"position,omitempty"` + Item []clientCertificateResponse `json:"item"` +} + +type generateClientCertificateRequest struct { + Description string `json:"description"` + Tags map[string]string `json:"tags"` +} + +func toClientCertificateResponse(cc *driver.ClientCertificate) clientCertificateResponse { + return clientCertificateResponse{ + ClientCertificateID: cc.ID, Description: cc.Description, PEMEncodedCertificate: cc.PEMEncodedCertificate, + CreatedDate: cc.CreatedDate, ExpirationDate: cc.ExpirationDate, Tags: cc.Tags, + } +} + +// serveClientCertificates handles /clientcertificates (GET list, POST +// generate) and /clientcertificates/{id} (GET, PATCH, DELETE). +func (h *Handler) serveClientCertificates(w http.ResponseWriter, r *http.Request, id string) { + if id != "" { + serveItem(w, r, + func(ops []driver.PatchOperation) (*driver.ClientCertificate, error) { + return h.ag.UpdateClientCertificate(r.Context(), id, ops) + }, + func() (*driver.ClientCertificate, error) { return h.ag.GetClientCertificate(r.Context(), id) }, + func() error { return h.ag.DeleteClientCertificate(r.Context(), id) }, + toClientCertificateResponse, + ) + + return + } + + switch r.Method { + case http.MethodGet: + page, ok := pageInput(w, r) + if !ok { + return + } + + res, err := h.ag.GetClientCertificates(r.Context(), page) + if err != nil { + writeErr(w, err) + return + } + + out := listClientCertificatesResponse{Position: res.Position, Item: make([]clientCertificateResponse, 0, len(res.Items))} + for i := range res.Items { + out.Item = append(out.Item, toClientCertificateResponse(&res.Items[i])) + } + + writeJSON(w, http.StatusOK, out) + case http.MethodPost: + var req generateClientCertificateRequest + if !decodeJSON(w, r, &req) { + return + } + + cc, err := h.ag.GenerateClientCertificate(r.Context(), driver.GenerateClientCertificateInput{ + Description: req.Description, Tags: req.Tags, + }) + if err != nil { + writeErr(w, err) + return + } + + writeJSON(w, http.StatusCreated, toClientCertificateResponse(cc)) + default: + writeMethodNotAllowed(w) + } +} + +// pageInput reads the position and limit query parameters. A limit that is +// not an integer is a BadRequest. +func pageInput(w http.ResponseWriter, r *http.Request) (driver.PageInput, bool) { + q := r.URL.Query() + in := driver.PageInput{Position: q.Get("position")} + + if raw := q.Get("limit"); raw != "" { + n, err := strconv.Atoi(raw) + if err != nil { + writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid limit specified: "+raw) + return in, false + } + + in.Limit = n + } + + return in, true +} + +// ownsTagsPath reports whether p is /tags/{arn} for a resource this handler +// tags: a REST API or a client certificate. Other API Gateway ARNs (the v2 +// /apis tree) and other services' ARNs fall through. +func ownsTagsPath(p string) bool { + arn, ok := strings.CutPrefix(p, tagsPrefix) + if !ok { + return false + } + + _, resource, found := strings.Cut(arn, "::") + + return found && strings.Contains(arn, ":apigateway:") && + (strings.HasPrefix(resource, controlPrefix+"/") || strings.HasPrefix(resource, certsPrefix+"/")) +} + +type tagsBody struct { + Tags map[string]string `json:"tags"` +} + +// serveTags handles /tags/{arn}: PUT=TagResource, DELETE=UntagResource, +// GET=GetTags. +func (h *Handler) serveTags(w http.ResponseWriter, r *http.Request, arn string) { + switch r.Method { + case http.MethodPut: + var req tagsBody + if !decodeJSON(w, r, &req) { + return + } + + if err := h.ag.TagResource(r.Context(), arn, req.Tags); err != nil { + writeErr(w, err) + return + } + + w.WriteHeader(http.StatusNoContent) + case http.MethodDelete: + if err := h.ag.UntagResource(r.Context(), arn, r.URL.Query()["tagKeys"]); err != nil { + writeErr(w, err) + return + } + + w.WriteHeader(http.StatusNoContent) + case http.MethodGet: + tags, err := h.ag.GetTags(r.Context(), arn) + if err != nil { + writeErr(w, err) + return + } + + writeJSON(w, http.StatusOK, tagsBody{Tags: tags}) + default: + writeMethodNotAllowed(w) + } +} diff --git a/server/aws/apigateway/documentation.go b/server/aws/apigateway/documentation.go new file mode 100644 index 000000000..c52aee14c --- /dev/null +++ b/server/aws/apigateway/documentation.go @@ -0,0 +1,244 @@ +package apigateway + +import ( + "io" + "net/http" + "strconv" + + "github.com/stackshy/cloudemu/v2/services/apigateway/driver" +) + +// Documentation sub-collections under /restapis/{id}/documentation. +const ( + docParts = "parts" + docVersions = "versions" +) + +type docLocation struct { + Type string `json:"type"` + Path string `json:"path,omitempty"` + Method string `json:"method,omitempty"` + StatusCode string `json:"statusCode,omitempty"` + Name string `json:"name,omitempty"` +} + +type docPartResponse struct { + ID string `json:"id"` + Location docLocation `json:"location"` + Properties string `json:"properties"` +} + +type listDocPartsResponse struct { + Position string `json:"position,omitempty"` + Item []docPartResponse `json:"item"` +} + +type createDocPartRequest struct { + Location docLocation `json:"location"` + Properties string `json:"properties"` +} + +type docPartIDsResponse struct { + IDs []string `json:"ids"` + Warnings []string `json:"warnings,omitempty"` +} + +type docVersionResponse struct { + Version string `json:"version"` + Description string `json:"description,omitempty"` + CreatedDate int64 `json:"createdDate"` +} + +type listDocVersionsResponse struct { + Position string `json:"position,omitempty"` + Item []docVersionResponse `json:"item"` +} + +type createDocVersionRequest struct { + DocumentationVersion string `json:"documentationVersion"` + StageName string `json:"stageName"` + Description string `json:"description"` +} + +func toDocPartResponse(p *driver.DocumentationPart) docPartResponse { + l := p.Location + + return docPartResponse{ + ID: p.ID, Properties: p.Properties, + Location: docLocation{Type: l.Type, Path: l.Path, Method: l.Method, StatusCode: l.StatusCode, Name: l.Name}, + } +} + +func toDocVersionResponse(v *driver.DocumentationVersion) docVersionResponse { + return docVersionResponse{Version: v.Version, Description: v.Description, CreatedDate: v.CreatedDate} +} + +// serveDocCollection handles /restapis/{id}/documentation/{parts|versions}. +func (h *Handler) serveDocCollection(w http.ResponseWriter, r *http.Request, id, coll string) { + switch coll { + case docParts: + h.serveDocParts(w, r, id) + case docVersions: + h.serveDocVersions(w, r, id) + default: + writeError(w, http.StatusNotFound, "NotFoundException", "unsupported API Gateway path") + } +} + +// serveDocItem handles /restapis/{id}/documentation/{parts|versions}/{item}. +func (h *Handler) serveDocItem(w http.ResponseWriter, r *http.Request, segs []string) { + id, item := segs[0], segs[3] + + switch { + case segs[1] == subDocs && segs[2] == docParts: + serveItem(w, r, + func(ops []driver.PatchOperation) (*driver.DocumentationPart, error) { + return h.ag.UpdateDocumentationPart(r.Context(), id, item, ops) + }, + func() (*driver.DocumentationPart, error) { return h.ag.GetDocumentationPart(r.Context(), id, item) }, + func() error { return h.ag.DeleteDocumentationPart(r.Context(), id, item) }, + toDocPartResponse, + ) + case segs[1] == subDocs && segs[2] == docVersions: + serveItem(w, r, + func(ops []driver.PatchOperation) (*driver.DocumentationVersion, error) { + return h.ag.UpdateDocumentationVersion(r.Context(), id, item, ops) + }, + func() (*driver.DocumentationVersion, error) { + return h.ag.GetDocumentationVersion(r.Context(), id, item) + }, + func() error { return h.ag.DeleteDocumentationVersion(r.Context(), id, item) }, + toDocVersionResponse, + ) + default: + writeError(w, http.StatusNotFound, "NotFoundException", "unsupported API Gateway path") + } +} + +// serveDocParts handles GET=GetDocumentationParts, POST=CreateDocumentationPart +// and PUT=ImportDocumentationParts. +func (h *Handler) serveDocParts(w http.ResponseWriter, r *http.Request, id string) { + switch r.Method { + case http.MethodGet: + h.getDocParts(w, r, id) + case http.MethodPost: + var req createDocPartRequest + if !decodeJSON(w, r, &req) { + return + } + + l := req.Location + + p, err := h.ag.CreateDocumentationPart(r.Context(), id, &driver.CreateDocumentationPartInput{ + Properties: req.Properties, + Location: driver.DocumentationPartLocation{ + Type: l.Type, Path: l.Path, Method: l.Method, StatusCode: l.StatusCode, Name: l.Name, + }, + }) + if err != nil { + writeErr(w, err) + return + } + + writeJSON(w, http.StatusCreated, toDocPartResponse(p)) + case http.MethodPut: + h.importDocParts(w, r, id) + default: + writeMethodNotAllowed(w) + } +} + +func (h *Handler) getDocParts(w http.ResponseWriter, r *http.Request, id string) { + page, ok := pageInput(w, r) + if !ok { + return + } + + q := r.URL.Query() + + res, err := h.ag.GetDocumentationParts(r.Context(), id, &driver.GetDocumentationPartsInput{ + Type: q.Get("type"), Path: q.Get("path"), NameQuery: q.Get("name"), + LocationStatus: q.Get("locationStatus"), PageInput: page, + }) + if err != nil { + writeErr(w, err) + return + } + + out := listDocPartsResponse{Position: res.Position, Item: make([]docPartResponse, 0, len(res.Items))} + for i := range res.Items { + out.Item = append(out.Item, toDocPartResponse(&res.Items[i])) + } + + writeJSON(w, http.StatusOK, out) +} + +// importDocParts reads the raw OpenAPI body plus the mode and failonwarnings +// query parameters. +func (h *Handler) importDocParts(w http.ResponseWriter, r *http.Request, id string) { + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBodyBytes)) + if err != nil { + writeError(w, http.StatusBadRequest, "BadRequestException", err.Error()) + return + } + + q := r.URL.Query() + failOnWarnings, _ := strconv.ParseBool(q.Get("failonwarnings")) + + res, err := h.ag.ImportDocumentationParts(r.Context(), id, driver.ImportDocumentationPartsInput{ + Mode: q.Get("mode"), FailOnWarnings: failOnWarnings, Body: body, + }) + if err != nil { + writeErr(w, err) + return + } + + out := docPartIDsResponse{IDs: res.IDs, Warnings: res.Warnings} + if out.IDs == nil { + out.IDs = []string{} + } + + writeJSON(w, http.StatusOK, out) +} + +// serveDocVersions handles GET=GetDocumentationVersions and +// POST=CreateDocumentationVersion. +func (h *Handler) serveDocVersions(w http.ResponseWriter, r *http.Request, id string) { + switch r.Method { + case http.MethodGet: + page, ok := pageInput(w, r) + if !ok { + return + } + + res, err := h.ag.GetDocumentationVersions(r.Context(), id, page) + if err != nil { + writeErr(w, err) + return + } + + out := listDocVersionsResponse{Position: res.Position, Item: make([]docVersionResponse, 0, len(res.Items))} + for i := range res.Items { + out.Item = append(out.Item, toDocVersionResponse(&res.Items[i])) + } + + writeJSON(w, http.StatusOK, out) + case http.MethodPost: + var req createDocVersionRequest + if !decodeJSON(w, r, &req) { + return + } + + v, err := h.ag.CreateDocumentationVersion(r.Context(), id, driver.CreateDocumentationVersionInput{ + Version: req.DocumentationVersion, StageName: req.StageName, Description: req.Description, + }) + if err != nil { + writeErr(w, err) + return + } + + writeJSON(w, http.StatusCreated, toDocVersionResponse(v)) + default: + writeMethodNotAllowed(w) + } +} diff --git a/server/aws/apigateway/documentation_e2e_test.go b/server/aws/apigateway/documentation_e2e_test.go new file mode 100644 index 000000000..6e1857483 --- /dev/null +++ b/server/aws/apigateway/documentation_e2e_test.go @@ -0,0 +1,162 @@ +package apigateway_test + +import ( + "net/http" + "net/url" + "strings" + "testing" +) + +func TestClientCertificateWire(t *testing.T) { + srv := newE2E(t) + base := srv.URL + + cc := doJSON(t, http.MethodPost, base+"/clientcertificates", `{"description":"d","tags":{"k":"v"}}`) + id, _ := cc["clientCertificateId"].(string) + pemCert, _ := cc["pemEncodedCertificate"].(string) + + if id == "" || !strings.HasPrefix(pemCert, "-----BEGIN CERTIFICATE-----") || cc["expirationDate"] == nil { + t.Fatalf("GenerateClientCertificate = %v", cc) + } + + doJSON(t, http.MethodPost, base+"/clientcertificates", `{}`) + + list := doJSON(t, http.MethodGet, base+"/clientcertificates?limit=1", "") + if items, _ := list["item"].([]any); len(items) != 1 || list["position"] == nil { + t.Fatalf("GetClientCertificates page = %v", list) + } + + upd := doJSON(t, http.MethodPatch, base+"/clientcertificates/"+id, + `{"patchOperations":[{"op":"replace","path":"/description","value":"new"}]}`) + if upd["description"] != "new" { + t.Fatalf("UpdateClientCertificate = %v", upd) + } + + arn := url.PathEscape("arn:aws:apigateway:us-east-1::/clientcertificates/" + id) + if status, raw, _ := doRaw(t, http.MethodPut, base+"/tags/"+arn, `{"tags":{"team":"a"}}`); status != http.StatusNoContent { + t.Fatalf("TagResource = %d %s", status, raw) + } + + if status, raw, _ := doRaw(t, http.MethodDelete, base+"/tags/"+arn+"?tagKeys=k", ""); status != http.StatusNoContent { + t.Fatalf("UntagResource = %d %s", status, raw) + } + + tags := doJSON(t, http.MethodGet, base+"/tags/"+arn, "") + if got, _ := tags["tags"].(map[string]any); len(got) != 1 || got["team"] != "a" { + t.Fatalf("GetTags = %v", tags) + } + + apiID := buildProxyAPI(t, base) + + assertWireError(t, http.MethodPatch, base+"/restapis/"+apiID+"/stages/prod", + `{"patchOperations":[{"op":"replace","path":"/clientCertificateId","value":"nosuch"}]}`, + http.StatusNotFound, "NotFoundException", "Invalid Client Certificate identifier specified") + + st := doJSON(t, http.MethodPatch, base+"/restapis/"+apiID+"/stages/prod", + `{"patchOperations":[{"op":"replace","path":"/clientCertificateId","value":"`+id+`"}]}`) + if st["clientCertificateId"] != id { + t.Fatalf("stage clientCertificateId = %v", st) + } + + if status, _, errType := doRaw(t, http.MethodDelete, base+"/clientcertificates/"+id, ""); status != http.StatusBadRequest || + errType != "BadRequestException" { + t.Fatalf("delete in-use certificate = %d %s", status, errType) + } + + assertWireError(t, http.MethodGet, base+"/clientcertificates/nosuch", "", + http.StatusNotFound, "NotFoundException", "Invalid Client Certificate identifier specified") +} + +func TestDocumentationWire(t *testing.T) { + srv := newE2E(t) + base := srv.URL + apiID := buildProxyAPI(t, base) + parts := base + "/restapis/" + apiID + "/documentation/parts" + + p := doJSON(t, http.MethodPost, parts, `{"location":{"type":"RESOURCE"},"properties":"{\"description\":\"root\"}"}`) + loc, _ := p["location"].(map[string]any) + + if loc["path"] != "/" || loc["type"] != "RESOURCE" { + t.Fatalf("CreateDocumentationPart location = %v", p) + } + + partID, _ := p["id"].(string) + + assertWireError(t, http.MethodPost, parts, `{"location":{"type":"RESOURCE","path":"/"},"properties":"{}"}`, + http.StatusConflict, "ConflictException", "Documentation part already exists for the specified location: type 'RESOURCE', path '/'.") + + status, _, errType := doRaw(t, http.MethodPost, parts, `{"location":{"type":"API","method":"GET"},"properties":"{}"}`) + if status != http.StatusBadRequest || errType != "BadRequestException" { + t.Fatalf("invalid location field = %d %s", status, errType) + } + + imported := doJSON(t, http.MethodPut, parts+"?mode=merge", + `{"swagger":"2.0","x-amazon-apigateway-documentation":{"documentationParts":[`+ + `{"location":{"type":"API"},"properties":{"description":"api"}}]}}`) + if ids, _ := imported["ids"].([]any); len(ids) != 1 { + t.Fatalf("ImportDocumentationParts = %v", imported) + } + + list := doJSON(t, http.MethodGet, parts+"?type=API", "") + if items, _ := list["item"].([]any); len(items) != 1 { + t.Fatalf("GetDocumentationParts type=API = %v", list) + } + + upd := doJSON(t, http.MethodPatch, parts+"/"+partID, + `{"patchOperations":[{"op":"replace","path":"/properties","value":"{\"description\":\"new\"}"}]}`) + if upd["properties"] != `{"description":"new"}` { + t.Fatalf("UpdateDocumentationPart = %v", upd) + } + + versions := base + "/restapis/" + apiID + "/documentation/versions" + + v := doJSON(t, http.MethodPost, versions, `{"documentationVersion":"1.0","stageName":"prod","description":"first"}`) + if v["version"] != "1.0" { + t.Fatalf("CreateDocumentationVersion = %v", v) + } + + st := doJSON(t, http.MethodGet, base+"/restapis/"+apiID+"/stages/prod", "") + if st["documentationVersion"] != "1.0" { + t.Fatalf("stage documentationVersion = %v", st) + } + + assertWireError(t, http.MethodPost, versions, `{"documentationVersion":"1.0"}`, + http.StatusConflict, "ConflictException", "Documentation version already exists") + + vl := doJSON(t, http.MethodGet, versions, "") + if items, _ := vl["item"].([]any); len(items) != 1 { + t.Fatalf("GetDocumentationVersions = %v", vl) + } + + if status, raw, _ := doRaw(t, http.MethodDelete, parts+"/"+partID, ""); status != http.StatusAccepted { + t.Fatalf("DeleteDocumentationPart = %d %s", status, raw) + } + + assertWireError(t, http.MethodGet, versions+"/9", "", + http.StatusNotFound, "NotFoundException", "Invalid Documentation version identifier specified") +} + +func TestAccountWire(t *testing.T) { + srv := newE2E(t) + + acct := doJSON(t, http.MethodGet, srv.URL+"/account", "") + throttle, _ := acct["throttleSettings"].(map[string]any) + + if throttle["rateLimit"] != float64(10000) || throttle["burstLimit"] != float64(5000) || acct["apiKeyVersion"] != "4" { + t.Fatalf("GetAccount = %v", acct) + } + + role := "arn:aws:iam::123456789012:role/apigw" + + upd := doJSON(t, http.MethodPatch, srv.URL+"/account", + `{"patchOperations":[{"op":"replace","path":"/cloudwatchRoleArn","value":"`+role+`"}]}`) + if upd["cloudwatchRoleArn"] != role { + t.Fatalf("UpdateAccount = %v", upd) + } + + status, _, errType := doRaw(t, http.MethodPatch, srv.URL+"/account", + `{"patchOperations":[{"op":"replace","path":"/apiKeyVersion","value":"3"}]}`) + if status != http.StatusBadRequest || errType != "BadRequestException" { + t.Fatalf("read-only account path = %d %s", status, errType) + } +} diff --git a/server/aws/apigateway/handler.go b/server/aws/apigateway/handler.go index c8c5e3fcc..f08058447 100644 --- a/server/aws/apigateway/handler.go +++ b/server/aws/apigateway/handler.go @@ -29,6 +29,9 @@ import ( const ( controlPrefix = "/restapis" + certsPrefix = "/clientcertificates" + accountPath = "/account" + tagsPrefix = "/tags/" userRequestMark = "_user_request_" executeAPIMarker = ".execute-api." contentTypeJSON = "application/json" @@ -40,6 +43,7 @@ const ( subResources = "resources" subDeployments = "deployments" subStages = "stages" + subDocs = "documentation" ) // Control-plane path segment counts (after the /restapis prefix is stripped). @@ -47,6 +51,7 @@ const ( segsAPI = 1 // {id} segsAPISub = 2 // {id}/{resources|deployments|stages} segsAPISubItem = 3 // {id}/{resources|stages}/{item} + segsDocItem = 4 // {id}/documentation/{parts|versions}/{item} segsMethod = 5 // {id}/resources/{rid}/methods/{httpMethod} segsIntegration = 6 // {id}/resources/{rid}/methods/{httpMethod}/integration ) @@ -66,7 +71,10 @@ func New(d driver.APIGateway) *Handler { // an S3 bucket literally named "restapis" would be shadowed (documented, and not // a real bucket name). func (*Handler) Matches(r *http.Request) bool { - return strings.HasPrefix(r.URL.Path, controlPrefix) || strings.Contains(r.Host, executeAPIMarker) + p := r.URL.Path + + return strings.HasPrefix(p, controlPrefix) || strings.Contains(r.Host, executeAPIMarker) || + p == certsPrefix || strings.HasPrefix(p, certsPrefix+"/") || p == accountPath || ownsTagsPath(p) } // ServeHTTP dispatches to the data plane (execute-api host or a _user_request_ @@ -77,6 +85,12 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.serveHostDataPlane(w, r) case isPathDataPlane(r): h.servePathDataPlane(w, r) + case r.URL.Path == accountPath: + h.serveAccount(w, r) + case r.URL.Path == certsPrefix || strings.HasPrefix(r.URL.Path, certsPrefix+"/"): + h.serveClientCertificates(w, r, strings.Trim(strings.TrimPrefix(r.URL.Path, certsPrefix), "/")) + case strings.HasPrefix(r.URL.Path, tagsPrefix): + h.serveTags(w, r, strings.TrimPrefix(r.URL.Path, tagsPrefix)) default: h.serveControlPlane(w, r) } @@ -117,6 +131,8 @@ func (h *Handler) serveControlPlane(w http.ResponseWriter, r *http.Request) { h.serveAPISub(w, r, segs[0], segs[1]) case segsAPISubItem: h.serveAPISubItem(w, r, segs) + case segsDocItem: + h.serveDocItem(w, r, segs) case segsMethod: h.serveMethod(w, r, segs) case segsIntegration: @@ -286,6 +302,8 @@ func (h *Handler) serveAPISubItem(w http.ResponseWriter, r *http.Request, segs [ h.serveDeploymentItem(w, r, id, item) case subStages: h.serveStageItem(w, r, id, item) + case subDocs: + h.serveDocCollection(w, r, id, item) default: writeError(w, http.StatusNotFound, "NotFoundException", "unsupported API Gateway path") } @@ -564,6 +582,7 @@ func (h *Handler) createStage(w http.ResponseWriter, r *http.Request, id string) st, err := h.ag.CreateStage(r.Context(), id, driver.CreateStageInput{ StageName: req.StageName, DeploymentID: req.DeploymentID, Description: req.Description, Variables: req.Variables, + DocumentationVersion: req.DocumentationVersion, }) if err != nil { writeErr(w, err) diff --git a/server/aws/apigateway/types.go b/server/aws/apigateway/types.go index c5a08f186..acab026cf 100644 --- a/server/aws/apigateway/types.go +++ b/server/aws/apigateway/types.go @@ -77,10 +77,11 @@ type createDeploymentRequest struct { // createStageRequest is the CreateStage request body. type createStageRequest struct { - StageName string `json:"stageName"` - DeploymentID string `json:"deploymentId"` - Description string `json:"description"` - Variables map[string]string `json:"variables"` + StageName string `json:"stageName"` + DeploymentID string `json:"deploymentId"` + Description string `json:"description"` + Variables map[string]string `json:"variables"` + DocumentationVersion string `json:"documentationVersion"` } // apiStatusAvailable is the RestApi apiStatus of a ready API. @@ -164,11 +165,13 @@ type listDeploymentsResponse struct { // stageResponse is the Stage wire object. type stageResponse struct { - StageName string `json:"stageName"` - DeploymentID string `json:"deploymentId,omitempty"` - Description string `json:"description,omitempty"` - CreatedDate int64 `json:"createdDate"` - Variables map[string]string `json:"variables,omitempty"` + StageName string `json:"stageName"` + DeploymentID string `json:"deploymentId,omitempty"` + Description string `json:"description,omitempty"` + CreatedDate int64 `json:"createdDate"` + Variables map[string]string `json:"variables,omitempty"` + ClientCertificateID string `json:"clientCertificateId,omitempty"` + DocumentationVersion string `json:"documentationVersion,omitempty"` } // listStagesResponse is the GetStages wire object. @@ -271,5 +274,6 @@ func toStageResponse(s *driver.Stage) stageResponse { return stageResponse{ StageName: s.StageName, DeploymentID: s.DeploymentID, Description: s.Description, CreatedDate: s.CreatedDate, Variables: s.Variables, + ClientCertificateID: s.ClientCertificateID, DocumentationVersion: s.DocumentationVersion, } } diff --git a/services/apigateway/driver/driver.go b/services/apigateway/driver/driver.go index 08f804cc7..47c9429a8 100644 --- a/services/apigateway/driver/driver.go +++ b/services/apigateway/driver/driver.go @@ -117,6 +117,12 @@ type Stage struct { Description string CreatedDate int64 Variables map[string]string + // ClientCertificateID is the client certificate the stage presents to HTTP + // backends; empty when none is attached. + ClientCertificateID string + // DocumentationVersion is the documentation snapshot associated with the + // stage; empty when none is. + DocumentationVersion string } // CreateRestAPIInput carries the fields CreateRestApi accepts. @@ -162,10 +168,11 @@ type CreateDeploymentInput struct { // CreateStageInput carries the fields CreateStage accepts. type CreateStageInput struct { - StageName string - DeploymentID string - Description string - Variables map[string]string + StageName string + DeploymentID string + Description string + Variables map[string]string + DocumentationVersion string } // ProxyRequest is a data-plane request to route through a deployed stage. @@ -195,6 +202,148 @@ type ProxyResponse struct { IsBase64Encoded bool } +// Documentation part location types (DocumentationPartLocation.type). +const ( + DocTypeAPI = "API" + DocTypeAuthorizer = "AUTHORIZER" + DocTypeModel = "MODEL" + DocTypeResource = "RESOURCE" + DocTypeMethod = "METHOD" + DocTypePathParameter = "PATH_PARAMETER" + DocTypeQueryParameter = "QUERY_PARAMETER" + DocTypeRequestHeader = "REQUEST_HEADER" + DocTypeRequestBody = "REQUEST_BODY" + DocTypeResponse = "RESPONSE" + DocTypeResponseHeader = "RESPONSE_HEADER" + DocTypeResponseBody = "RESPONSE_BODY" +) + +// ClientCertificate is an API Gateway generated, self-signed client +// certificate. PEMEncodedCertificate holds only the public certificate. +type ClientCertificate struct { + ID string + Description string + PEMEncodedCertificate string + CreatedDate int64 // unix seconds + ExpirationDate int64 // unix seconds + Tags map[string]string +} + +// GenerateClientCertificateInput carries the fields GenerateClientCertificate +// accepts. +type GenerateClientCertificateInput struct { + Description string + Tags map[string]string +} + +// PageInput is the position/limit pair every paged Get* collection takes. A +// zero Limit selects the AWS default of 25. +type PageInput struct { + Position string + Limit int +} + +// ClientCertificatePage is one page of GetClientCertificates. Position is empty +// on the last page. +type ClientCertificatePage struct { + Items []ClientCertificate + Position string +} + +// DocumentationPartLocation identifies the API entity a documentation part +// describes. Fields that do not apply to Type are empty. +type DocumentationPartLocation struct { + Type string + Path string + Method string + StatusCode string + Name string +} + +// DocumentationPart is one documentation entry of a REST API. Properties is +// the JSON content map encoded as a string. +type DocumentationPart struct { + ID string + Location DocumentationPartLocation + Properties string +} + +// CreateDocumentationPartInput carries the fields CreateDocumentationPart +// accepts. +type CreateDocumentationPartInput struct { + Location DocumentationPartLocation + Properties string +} + +// GetDocumentationPartsInput carries the GetDocumentationParts filters. +// NameQuery matches location names containing it; LocationStatus is +// DOCUMENTED or UNDOCUMENTED. +type GetDocumentationPartsInput struct { + Type string + Path string + NameQuery string + LocationStatus string + PageInput +} + +// DocumentationPartPage is one page of GetDocumentationParts. +type DocumentationPartPage struct { + Items []DocumentationPart + Position string +} + +// ImportDocumentationPartsInput carries an ImportDocumentationParts request. +// Body is an OpenAPI/Swagger document (JSON or YAML) whose +// x-amazon-apigateway-documentation extension lists the parts. Mode is merge +// (the default) or overwrite. +type ImportDocumentationPartsInput struct { + Mode string + FailOnWarnings bool + Body []byte +} + +// DocumentationPartIDs is the ImportDocumentationParts result. +type DocumentationPartIDs struct { + IDs []string + Warnings []string +} + +// DocumentationVersion is a named snapshot of a REST API's documentation +// parts. +type DocumentationVersion struct { + Version string + Description string + CreatedDate int64 +} + +// CreateDocumentationVersionInput carries the fields CreateDocumentationVersion +// accepts. A non-empty StageName associates the new version with that stage. +type CreateDocumentationVersionInput struct { + Version string + StageName string + Description string +} + +// DocumentationVersionPage is one page of GetDocumentationVersions. +type DocumentationVersionPage struct { + Items []DocumentationVersion + Position string +} + +// ThrottleSettings is the account-level request rate and burst limit. +type ThrottleSettings struct { + BurstLimit int + RateLimit float64 +} + +// Account is the per-region API Gateway account settings resource. +type Account struct { + CloudWatchRoleARN string + Throttle ThrottleSettings + Features []string + APIKeyVersion string +} + // APIGateway is the interface an API Gateway provider implements: the REST API // v1 control plane plus the InvokeRoute data-plane entry point. type APIGateway interface { @@ -248,6 +397,50 @@ type APIGateway interface { UpdateStage(ctx context.Context, restAPIID, stageName string, ops []PatchOperation) (*Stage, error) DeleteStage(ctx context.Context, restAPIID, stageName string) error + // GenerateClientCertificate creates a self-signed client certificate valid + // for 365 days. + GenerateClientCertificate(ctx context.Context, in GenerateClientCertificateInput) (*ClientCertificate, error) + GetClientCertificate(ctx context.Context, id string) (*ClientCertificate, error) + GetClientCertificates(ctx context.Context, page PageInput) (*ClientCertificatePage, error) + // UpdateClientCertificate applies a patchOperations document (only + // /description is mutable). + UpdateClientCertificate(ctx context.Context, id string, ops []PatchOperation) (*ClientCertificate, error) + // DeleteClientCertificate removes a certificate. It fails while a stage + // still references it. + DeleteClientCertificate(ctx context.Context, id string) error + + CreateDocumentationPart(ctx context.Context, restAPIID string, in *CreateDocumentationPartInput) (*DocumentationPart, error) + GetDocumentationPart(ctx context.Context, restAPIID, partID string) (*DocumentationPart, error) + GetDocumentationParts(ctx context.Context, restAPIID string, in *GetDocumentationPartsInput) (*DocumentationPartPage, error) + // UpdateDocumentationPart applies a patchOperations document (only + // /properties is mutable). + UpdateDocumentationPart(ctx context.Context, restAPIID, partID string, ops []PatchOperation) (*DocumentationPart, error) + DeleteDocumentationPart(ctx context.Context, restAPIID, partID string) error + ImportDocumentationParts(ctx context.Context, restAPIID string, in ImportDocumentationPartsInput) (*DocumentationPartIDs, error) + + // CreateDocumentationVersion snapshots the API's current documentation + // parts under a version name. + CreateDocumentationVersion(ctx context.Context, restAPIID string, in CreateDocumentationVersionInput) (*DocumentationVersion, error) + GetDocumentationVersion(ctx context.Context, restAPIID, version string) (*DocumentationVersion, error) + GetDocumentationVersions(ctx context.Context, restAPIID string, page PageInput) (*DocumentationVersionPage, error) + // UpdateDocumentationVersion applies a patchOperations document (only + // /description is mutable). + UpdateDocumentationVersion(ctx context.Context, restAPIID, version string, ops []PatchOperation) (*DocumentationVersion, error) + // DeleteDocumentationVersion removes a version. It fails while a stage + // still references it. + DeleteDocumentationVersion(ctx context.Context, restAPIID, version string) error + + GetAccount(ctx context.Context) (*Account, error) + // UpdateAccount applies a patchOperations document (/cloudwatchRoleArn + // replace or remove, /features add or remove). + UpdateAccount(ctx context.Context, ops []PatchOperation) (*Account, error) + + // TagResource, UntagResource and GetTags manage tags on a REST API or client + // certificate addressed by its ARN. + TagResource(ctx context.Context, arn string, tags map[string]string) error + UntagResource(ctx context.Context, arn string, keys []string) error + GetTags(ctx context.Context, arn string) (map[string]string, error) + // 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,