diff --git a/.github/workflows/offline-tests.yml b/.github/workflows/offline-tests.yml index 20ce51d..6734bb4 100644 --- a/.github/workflows/offline-tests.yml +++ b/.github/workflows/offline-tests.yml @@ -7,7 +7,7 @@ on: workflow_dispatch: jobs: - hurl-tests: + offline-e2e-tests: timeout-minutes: 3 runs-on: ubuntu-latest steps: @@ -16,26 +16,15 @@ jobs: with: fetch-depth: 0 - - name: Start Terrable - working-directory: ./tests - run: |- - bash ./_start.sh & + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod - - name: Install Hurl - run: | - VERSION=6.0.0 - curl --location --remote-name https://github.com/Orange-OpenSource/hurl/releases/download/$VERSION/hurl_${VERSION}_amd64.deb - sudo apt update && sudo apt install ./hurl_${VERSION}_amd64.deb - - - name: Wait for API to be ready - run: | - timeout 60s bash -c 'while [[ "$(curl -s -o /dev/null -w ''%{http_code}'' 127.0.0.1:8081)" != "200" ]]; do sleep 2; done' || false - - - name: Run Hurl tests - run: | - hurl --test --glob *.hurl - working-directory: ./tests/requests + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 - - name: Stop API - run: pkill terrable - if: always() + - name: Run offline end-to-end tests + run: go test -tags=e2e -v ./tests diff --git a/config/terrable_config.go b/config/terrable_config.go index 1a524f6..22b1dcb 100644 --- a/config/terrable_config.go +++ b/config/terrable_config.go @@ -3,6 +3,8 @@ package config type TerrableConfig struct { Handlers []HandlerMapping EnvironmentVariables map[string]string + HttpApi *APIGatewayConfig + RestApi *APIGatewayConfig Timeout int } @@ -13,3 +15,28 @@ type HandlerMapping struct { Sqs map[string]interface{} Timeout int } + +type APIGatewayConfig struct { + Cors *CorsConfig +} + +type CorsConfig struct { + AllowOrigins []string + AllowMethods []string + AllowHeaders []string + ExposeHeaders []string + AllowCredentials bool + MaxAge int +} + +func (config TerrableConfig) EffectiveCorsConfig() *CorsConfig { + if config.HttpApi != nil && config.HttpApi.Cors != nil { + return config.HttpApi.Cors + } + + if config.RestApi != nil && config.RestApi.Cors != nil { + return config.RestApi.Cors + } + + return nil +} diff --git a/offline/cors.go b/offline/cors.go new file mode 100644 index 0000000..8d9055c --- /dev/null +++ b/offline/cors.go @@ -0,0 +1,194 @@ +package offline + +import ( + "net/http" + "slices" + "sort" + "strconv" + "strings" + + "github.com/gorilla/mux" + "github.com/terrable-dev/terrable/config" +) + +type implicitOptionsRoute struct { + Path string + AllowedMethods []string +} + +func registerCORSMiddleware(r *mux.Router, terrableConfig *config.TerrableConfig) { + corsConfig := terrableConfig.EffectiveCorsConfig() + if corsConfig == nil { + return + } + + r.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + applyCORSResponseHeaders(w, r, corsConfig) + next.ServeHTTP(w, r) + }) + }) +} + +func registerImplicitOptionsRoutes(r *mux.Router, terrableConfig *config.TerrableConfig) { + corsConfig := terrableConfig.EffectiveCorsConfig() + if corsConfig == nil { + return + } + + for _, route := range buildImplicitOptionsRoutes(terrableConfig) { + route := route + r.HandleFunc(route.Path, func(w http.ResponseWriter, r *http.Request) { + applyCORSResponseHeaders(w, r, corsConfig) + if len(route.AllowedMethods) > 0 { + w.Header().Set("Access-Control-Allow-Methods", strings.Join(route.AllowedMethods, ", ")) + } + + if len(corsConfig.AllowHeaders) > 0 { + w.Header().Set("Access-Control-Allow-Headers", strings.Join(corsConfig.AllowHeaders, ", ")) + } else if requestedHeaders := r.Header.Get("Access-Control-Request-Headers"); requestedHeaders != "" { + w.Header().Set("Access-Control-Allow-Headers", requestedHeaders) + addVaryHeader(w, "Access-Control-Request-Headers") + } + + if corsConfig.MaxAge > 0 { + w.Header().Set("Access-Control-Max-Age", strconv.Itoa(corsConfig.MaxAge)) + } + + w.WriteHeader(http.StatusNoContent) + }).Methods(http.MethodOptions) + } +} + +func buildImplicitOptionsRoutes(terrableConfig *config.TerrableConfig) []implicitOptionsRoute { + corsConfig := terrableConfig.EffectiveCorsConfig() + if corsConfig == nil { + return nil + } + + explicitOptionsPaths := make(map[string]struct{}) + pathMethods := make(map[string]map[string]struct{}) + + for _, handler := range terrableConfig.Handlers { + for method, path := range handler.Http { + normalisedMethod := strings.ToUpper(method) + + if _, ok := pathMethods[path]; !ok { + pathMethods[path] = make(map[string]struct{}) + } + + pathMethods[path][normalisedMethod] = struct{}{} + + if normalisedMethod == http.MethodOptions { + explicitOptionsPaths[path] = struct{}{} + } + } + } + + var routes []implicitOptionsRoute + + for path, methods := range pathMethods { + if _, hasExplicitOptions := explicitOptionsPaths[path]; hasExplicitOptions { + continue + } + + routes = append(routes, implicitOptionsRoute{ + Path: path, + AllowedMethods: resolveAllowedMethods(methods, corsConfig.AllowMethods), + }) + } + + sort.Slice(routes, func(i, j int) bool { + return routes[i].Path < routes[j].Path + }) + + return routes +} + +func resolveAllowedMethods(routeMethods map[string]struct{}, configuredMethods []string) []string { + if len(configuredMethods) > 0 { + return uniqueSortedUppercase(configuredMethods) + } + + allowedMethods := make([]string, 0, len(routeMethods)+1) + + for method := range routeMethods { + allowedMethods = append(allowedMethods, method) + } + + if _, hasOptions := routeMethods[http.MethodOptions]; !hasOptions { + allowedMethods = append(allowedMethods, http.MethodOptions) + } + + sort.Strings(allowedMethods) + return allowedMethods +} + +func applyCORSResponseHeaders(w http.ResponseWriter, r *http.Request, corsConfig *config.CorsConfig) { + allowOrigin := resolveAllowOrigin(r.Header.Get("Origin"), corsConfig) + if allowOrigin != "" { + w.Header().Set("Access-Control-Allow-Origin", allowOrigin) + if allowOrigin != "*" { + addVaryHeader(w, "Origin") + } + } + + if corsConfig.AllowCredentials { + w.Header().Set("Access-Control-Allow-Credentials", "true") + } + + if len(corsConfig.ExposeHeaders) > 0 && r.Method != http.MethodOptions { + w.Header().Set("Access-Control-Expose-Headers", strings.Join(corsConfig.ExposeHeaders, ", ")) + } +} + +func resolveAllowOrigin(requestOrigin string, corsConfig *config.CorsConfig) string { + if len(corsConfig.AllowOrigins) == 0 { + return "" + } + + if slices.Contains(corsConfig.AllowOrigins, "*") && !corsConfig.AllowCredentials { + return "*" + } + + if requestOrigin != "" && slices.Contains(corsConfig.AllowOrigins, requestOrigin) { + return requestOrigin + } + + if len(corsConfig.AllowOrigins) == 1 { + return corsConfig.AllowOrigins[0] + } + + return "" +} + +func uniqueSortedUppercase(values []string) []string { + seen := make(map[string]struct{}, len(values)) + normalised := make([]string, 0, len(values)) + + for _, value := range values { + upperValue := strings.ToUpper(value) + if _, ok := seen[upperValue]; ok { + continue + } + + seen[upperValue] = struct{}{} + normalised = append(normalised, upperValue) + } + + sort.Strings(normalised) + return normalised +} + +func addVaryHeader(w http.ResponseWriter, value string) { + existingValues := w.Header().Values("Vary") + for _, existingValue := range existingValues { + for _, vary := range strings.Split(existingValue, ",") { + if strings.TrimSpace(vary) == value { + return + } + } + } + + w.Header().Add("Vary", value) +} diff --git a/offline/cors_test.go b/offline/cors_test.go new file mode 100644 index 0000000..571be65 --- /dev/null +++ b/offline/cors_test.go @@ -0,0 +1,152 @@ +package offline + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" + "github.com/terrable-dev/terrable/config" +) + +func TestBuildImplicitOptionsRoutes(t *testing.T) { + terrableConfig := &config.TerrableConfig{ + HttpApi: &config.APIGatewayConfig{ + Cors: &config.CorsConfig{ + AllowMethods: []string{"get", "post", "options"}, + }, + }, + Handlers: []config.HandlerMapping{ + { + Name: "ItemsHandler", + Http: map[string]string{ + "GET": "/items", + "POST": "/items", + }, + }, + { + Name: "ExplicitOptionsHandler", + Http: map[string]string{ + "GET": "/health", + "OPTIONS": "/health", + }, + }, + }, + } + + routes := buildImplicitOptionsRoutes(terrableConfig) + if len(routes) != 1 { + t.Fatalf("expected exactly one implicit OPTIONS route, got %d", len(routes)) + } + + if routes[0].Path != "/items" { + t.Fatalf("expected /items implicit OPTIONS route, got %s", routes[0].Path) + } + + expectedMethods := []string{"GET", "OPTIONS", "POST"} + if len(routes[0].AllowedMethods) != len(expectedMethods) { + t.Fatalf("expected %d allowed methods, got %d", len(expectedMethods), len(routes[0].AllowedMethods)) + } + + for index, expectedMethod := range expectedMethods { + if routes[0].AllowedMethods[index] != expectedMethod { + t.Fatalf("expected allowed method %s at index %d, got %s", expectedMethod, index, routes[0].AllowedMethods[index]) + } + } +} + +func TestRegisterImplicitOptionsRoutes(t *testing.T) { + terrableConfig := &config.TerrableConfig{ + HttpApi: &config.APIGatewayConfig{ + Cors: &config.CorsConfig{ + AllowOrigins: []string{"https://app.example.com"}, + AllowMethods: []string{"GET", "POST"}, + AllowHeaders: []string{"content-type", "authorization"}, + AllowCredentials: true, + MaxAge: 600, + }, + }, + Handlers: []config.HandlerMapping{ + { + Name: "ItemsHandler", + Http: map[string]string{ + "GET": "/items", + }, + }, + }, + } + + router := mux.NewRouter() + registerCORSMiddleware(router, terrableConfig) + registerImplicitOptionsRoutes(router, terrableConfig) + + request := httptest.NewRequest(http.MethodOptions, "/items", nil) + request.Header.Set("Origin", "https://app.example.com") + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusNoContent { + t.Fatalf("expected 204 response, got %d", recorder.Code) + } + + if allowOrigin := recorder.Header().Get("Access-Control-Allow-Origin"); allowOrigin != "https://app.example.com" { + t.Fatalf("expected Access-Control-Allow-Origin header to be echoed, got %q", allowOrigin) + } + + if allowMethods := recorder.Header().Get("Access-Control-Allow-Methods"); allowMethods != "GET, POST" { + t.Fatalf("expected configured allow methods, got %q", allowMethods) + } + + if allowHeaders := recorder.Header().Get("Access-Control-Allow-Headers"); allowHeaders != "content-type, authorization" { + t.Fatalf("expected configured allow headers, got %q", allowHeaders) + } + + if allowCredentials := recorder.Header().Get("Access-Control-Allow-Credentials"); allowCredentials != "true" { + t.Fatalf("expected Access-Control-Allow-Credentials=true, got %q", allowCredentials) + } + + if maxAge := recorder.Header().Get("Access-Control-Max-Age"); maxAge != "600" { + t.Fatalf("expected Access-Control-Max-Age=600, got %q", maxAge) + } +} + +func TestCORSMiddlewareAppliesHeadersToStandardResponses(t *testing.T) { + terrableConfig := &config.TerrableConfig{ + RestApi: &config.APIGatewayConfig{ + Cors: &config.CorsConfig{ + AllowOrigins: []string{"https://app.example.com"}, + ExposeHeaders: []string{"x-request-id"}, + AllowCredentials: true, + }, + }, + } + + router := mux.NewRouter() + registerCORSMiddleware(router, terrableConfig) + router.HandleFunc("/items", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }).Methods(http.MethodGet) + + request := httptest.NewRequest(http.MethodGet, "/items", nil) + request.Header.Set("Origin", "https://app.example.com") + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("expected 200 response, got %d", recorder.Code) + } + + if allowOrigin := recorder.Header().Get("Access-Control-Allow-Origin"); allowOrigin != "https://app.example.com" { + t.Fatalf("expected Access-Control-Allow-Origin header to be echoed, got %q", allowOrigin) + } + + if exposeHeaders := recorder.Header().Get("Access-Control-Expose-Headers"); exposeHeaders != "x-request-id" { + t.Fatalf("expected Access-Control-Expose-Headers to be set, got %q", exposeHeaders) + } + + if vary := recorder.Header().Get("Vary"); vary != "Origin" { + t.Fatalf("expected Vary header to include Origin, got %q", vary) + } +} diff --git a/offline/handler_server.go b/offline/handler_server.go index 545cfb9..4d5c798 100644 --- a/offline/handler_server.go +++ b/offline/handler_server.go @@ -24,18 +24,10 @@ type HandlerOutput struct { err error } -func ServeHandler(handlerInstance *HandlerInstance, r *mux.Router) { +func RegisterHandler(handlerInstance *HandlerInstance, r *mux.Router, np *NodeProcess) { inputFiles := handlerInstance.CompileHandler() go handlerInstance.WatchForChanges(inputFiles) - np, err := GetNodeProcess() - - if err != nil { - panic(err) - } - - defer np.Close() - handleRequestFunc := func(w http.ResponseWriter, r *http.Request, code string) { handlerExecutionMutex.Lock() defer handlerExecutionMutex.Unlock() @@ -72,8 +64,6 @@ func ServeHandler(handlerInstance *HandlerInstance, r *mux.Router) { handleRequestFunc(w, r, code) }).Methods("POST") } - - np.cmd.Wait() } func sendResult(startTime time.Time, w http.ResponseWriter, outputChannel chan HandlerOutput) { diff --git a/offline/offline.go b/offline/offline.go index 5543fe0..86aea88 100644 --- a/offline/offline.go +++ b/offline/offline.go @@ -4,18 +4,16 @@ import ( "bufio" "errors" "fmt" - "log" - "net" - "net/http" - "os" - "strings" - "sync" - "github.com/fatih/color" "github.com/gorilla/mux" "github.com/jedib0t/go-pretty/v6/table" "github.com/terrable-dev/terrable/config" "github.com/terrable-dev/terrable/utils" + "log" + "net" + "net/http" + "os" + "strings" ) var DebugConfig config.DebugConfig @@ -44,9 +42,6 @@ func Run(filePath string, moduleName string, port string, debugConfig config.Deb printConfig(*terrableConfig, activePort) - var wg sync.WaitGroup - defer wg.Wait() - // Read environment variables from the specified env file (if provided) var fileEnvVars map[string]string if envFile != "" { @@ -57,6 +52,8 @@ func Run(filePath string, moduleName string, port string, debugConfig config.Deb } r := mux.NewRouter() + registerCORSMiddleware(r, terrableConfig) + registerImplicitOptionsRoutes(r, terrableConfig) // Not Found handlers r.MethodNotAllowedHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -71,18 +68,18 @@ func Run(filePath string, moduleName string, port string, debugConfig config.Deb w.Write([]byte(`{"message": "Not Found"}`)) }) - // Start compiling and serving each handler - for _, handler := range terrableConfig.Handlers { - wg.Add(1) - - go func(handler config.HandlerMapping) { - defer wg.Done() + np, err := GetNodeProcess() + if err != nil { + return err + } + defer np.Close() - ServeHandler(&HandlerInstance{ - handlerConfig: handler, - envVars: mergeEnvMaps(terrableConfig.EnvironmentVariables, mergeEnvMaps(terrableConfig.EnvironmentVariables, fileEnvVars)), - }, r) - }(handler) + // Compile and register each handler before serving requests. + for _, handler := range terrableConfig.Handlers { + RegisterHandler(&HandlerInstance{ + handlerConfig: handler, + envVars: mergeEnvMaps(terrableConfig.EnvironmentVariables, mergeEnvMaps(terrableConfig.EnvironmentVariables, fileEnvVars)), + }, r, np) } server := &http.Server{ @@ -176,6 +173,20 @@ func printConfig(config config.TerrableConfig, port int) { } } + for _, route := range buildImplicitOptionsRoutes(&config) { + totalEndpoints++ + + url := fmt.Sprintf("%s%s", + hostColor(fmt.Sprintf("http://localhost:%d", port)), + pathColor(route.Path)) + + t.AppendRow(table.Row{ + methodColor(http.MethodOptions), + url, + handlerNameColor("(CORS)"), + }) + } + if hasSqsQueues { t.AppendRow(table.Row{ "\nSQS Handlers\n", diff --git a/offline/offline_test.go b/offline/offline_test.go index 137ee48..5aa6853 100644 --- a/offline/offline_test.go +++ b/offline/offline_test.go @@ -12,6 +12,11 @@ import ( func TestPrintConfig(t *testing.T) { testConfig := config.TerrableConfig{ + HttpApi: &config.APIGatewayConfig{ + Cors: &config.CorsConfig{ + AllowOrigins: []string{"*"}, + }, + }, Handlers: []config.HandlerMapping{ { Name: "Handler1", @@ -58,8 +63,10 @@ func TestPrintConfig(t *testing.T) { // Test for minimal required content without formatting expectedEndpoints := []string{ "GET http://localhost:1234/path1 (Handler1) ", + "OPTIONS http://localhost:1234/path1 (CORS)", "POST http://localhost:1234/path1 (Handler1) ", "GET http://localhost:1234/path2 (Handler2) ", + "OPTIONS http://localhost:1234/path2 (CORS)", "POST http://localhost:1234/_sqs/SqsHandler (SqsHandler)", } diff --git a/samples/integration/core/.env.sample b/samples/integration/core/.env.sample new file mode 100644 index 0000000..3115ed8 --- /dev/null +++ b/samples/integration/core/.env.sample @@ -0,0 +1,2 @@ +ENV_FILE_VAL=value-from-env-file +ENV_FILE_OVERRIDE=overridden-value diff --git a/samples/integration/core/offline.tf b/samples/integration/core/offline.tf new file mode 100644 index 0000000..b7aaeb3 --- /dev/null +++ b/samples/integration/core/offline.tf @@ -0,0 +1,68 @@ +module "offline_core" { + environment_variables = { + GLOBAL_ENV = "global-env-var" + } + + timeout = 3 + + handlers = { + EchoHandler = { + source = "./src/Echo.ts" + http = { + GET = "/" + POST = "/" + PUT = "/" + } + } + + EchoCallback = { + source = "./src/EchoCallback.ts" + http = { + GET = "/echo-callback" + } + } + + EchoEnvTest = { + source = "./src/Echo.ts" + http = { + GET = "/echo-env-test" + } + } + + DelayedHandler = { + source = "./src/Delayed.ts" + http = { + GET = "/delayed" + } + } + + TimeoutDelay = { + timeout = 1 + source = "./src/TimeoutDelay.ts" + http = { + GET = "/timeout" + } + } + + SqsHandler = { + source = "./src/Sqs.ts" + sqs = { + queue = "arn:aws:sqs:eu-west-1:000000000000:test-queue" + } + } + + CollisionOne = { + source = "./src/Collision1/Collision.ts" + http = { + GET = "/collision1" + } + } + + CollisionTwo = { + source = "./src/Collision2/Collision.ts" + http = { + GET = "/collision2" + } + } + } +} diff --git a/samples/simple/src/Collision1/Collision.ts b/samples/integration/core/src/Collision1/Collision.ts similarity index 68% rename from samples/simple/src/Collision1/Collision.ts rename to samples/integration/core/src/Collision1/Collision.ts index 0b941d8..3b380b7 100644 --- a/samples/simple/src/Collision1/Collision.ts +++ b/samples/integration/core/src/Collision1/Collision.ts @@ -1,4 +1,4 @@ -const handler = async (event) => { +const handler = async () => { return { statusCode: 200, body: JSON.stringify({ @@ -7,4 +7,4 @@ const handler = async (event) => { } } -export { handler }; \ No newline at end of file +export { handler }; diff --git a/samples/simple/src/Collision2/Collision.ts b/samples/integration/core/src/Collision2/Collision.ts similarity index 68% rename from samples/simple/src/Collision2/Collision.ts rename to samples/integration/core/src/Collision2/Collision.ts index 47fdb80..a1ec47a 100644 --- a/samples/simple/src/Collision2/Collision.ts +++ b/samples/integration/core/src/Collision2/Collision.ts @@ -1,4 +1,4 @@ -const handler = async (event) => { +const handler = async () => { return { statusCode: 200, body: JSON.stringify({ @@ -7,4 +7,4 @@ const handler = async (event) => { } } -export { handler }; \ No newline at end of file +export { handler }; diff --git a/samples/simple/src/Delayed.ts b/samples/integration/core/src/Delayed.ts similarity index 86% rename from samples/simple/src/Delayed.ts rename to samples/integration/core/src/Delayed.ts index 3b428de..63c77b3 100644 --- a/samples/simple/src/Delayed.ts +++ b/samples/integration/core/src/Delayed.ts @@ -1,6 +1,6 @@ import { DoPromise } from "./Utils"; -const handler = async (event) => { +const handler = async () => { const before = new Date().getTime(); await DoPromise(2000); @@ -18,4 +18,4 @@ const handler = async (event) => { } } -export { handler }; \ No newline at end of file +export { handler }; diff --git a/samples/simple/src/Echo.ts b/samples/integration/core/src/Echo.ts similarity index 94% rename from samples/simple/src/Echo.ts rename to samples/integration/core/src/Echo.ts index db99f4c..b33bbd6 100644 --- a/samples/simple/src/Echo.ts +++ b/samples/integration/core/src/Echo.ts @@ -12,4 +12,4 @@ const handler = async (event) => { } } -export { handler }; \ No newline at end of file +export { handler }; diff --git a/samples/simple/src/EchoCallback.ts b/samples/integration/core/src/EchoCallback.ts similarity index 95% rename from samples/simple/src/EchoCallback.ts rename to samples/integration/core/src/EchoCallback.ts index eccb45a..56d7fbb 100644 --- a/samples/simple/src/EchoCallback.ts +++ b/samples/integration/core/src/EchoCallback.ts @@ -13,8 +13,7 @@ const handler = (event, context, callback) => { context: context, }), }) - }) } -export { handler }; \ No newline at end of file +export { handler }; diff --git a/samples/integration/core/src/Sqs.ts b/samples/integration/core/src/Sqs.ts new file mode 100644 index 0000000..2484e8a --- /dev/null +++ b/samples/integration/core/src/Sqs.ts @@ -0,0 +1,22 @@ +const handler = async (event) => { + const [record] = event.Records; + + return { + statusCode: 200, + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + recordCount: event.Records.length, + firstRecord: { + body: record.body, + eventSource: record.eventSource, + eventSourceARN: record.eventSourceARN, + awsRegion: record.awsRegion, + approximateReceiveCount: record.attributes.ApproximateReceiveCount, + }, + }), + } +} + +export { handler }; diff --git a/samples/integration/core/src/TimeoutDelay.ts b/samples/integration/core/src/TimeoutDelay.ts new file mode 100644 index 0000000..a060ea3 --- /dev/null +++ b/samples/integration/core/src/TimeoutDelay.ts @@ -0,0 +1,12 @@ +import { DoPromise } from "./Utils"; + +// Configured by the core offline integration fixture to time out after 1 second. +const handler = async () => { + await DoPromise(2000); + + return { + statusCode: 200, + } +} + +export { handler }; diff --git a/samples/simple/src/Utils.ts b/samples/integration/core/src/Utils.ts similarity index 50% rename from samples/simple/src/Utils.ts rename to samples/integration/core/src/Utils.ts index db20420..54c2b0a 100644 --- a/samples/simple/src/Utils.ts +++ b/samples/integration/core/src/Utils.ts @@ -1,11 +1,7 @@ -export function MyUtil(): number { - return 321; -} - export async function DoPromise(timeout): Promise { - return new Promise((res, rej) => { + return new Promise((resolve) => { setTimeout(() => { - res(true); + resolve(true); }, timeout); }) -} \ No newline at end of file +} diff --git a/samples/integration/http-api-cors/offline.tf b/samples/integration/http-api-cors/offline.tf new file mode 100644 index 0000000..6333b40 --- /dev/null +++ b/samples/integration/http-api-cors/offline.tf @@ -0,0 +1,23 @@ +module "http_api_cors" { + http_api = { + cors_configuration = { + allow_origins = ["https://app.example.com"] + allow_methods = ["GET", "POST", "PUT", "OPTIONS"] + allow_headers = ["content-type", "authorization"] + expose_headers = ["x-terrable-request-id"] + allow_credentials = true + max_age = 600 + } + } + + handlers = { + EchoHandler = { + source = "./src/Echo.ts" + http = { + GET = "/" + POST = "/" + PUT = "/" + } + } + } +} diff --git a/samples/integration/http-api-cors/src/Echo.ts b/samples/integration/http-api-cors/src/Echo.ts new file mode 100644 index 0000000..b33bbd6 --- /dev/null +++ b/samples/integration/http-api-cors/src/Echo.ts @@ -0,0 +1,15 @@ +const handler = async (event) => { + return { + statusCode: 200, + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + queryStringParameters: event.queryStringParameters, + event: event, + env: process.env, + }), + } +} + +export { handler }; diff --git a/samples/integration/rest-api-cors/offline.tf b/samples/integration/rest-api-cors/offline.tf new file mode 100644 index 0000000..795ed8f --- /dev/null +++ b/samples/integration/rest-api-cors/offline.tf @@ -0,0 +1,31 @@ +module "rest_api_cors" { + rest_api = { + endpoint_type = "REGIONAL" + cors = { + allow_origins = ["https://app.example.com"] + allow_methods = ["GET", "POST", "PUT", "OPTIONS"] + allow_headers = ["content-type", "authorization"] + expose_headers = ["x-terrable-request-id"] + allow_credentials = true + max_age = 600 + } + } + + handlers = { + EchoHandler = { + source = "./src/Echo.ts" + http = { + GET = "/" + POST = "/" + PUT = "/" + } + } + + EchoCallback = { + source = "./src/EchoCallback.ts" + http = { + GET = "/echo-callback" + } + } + } +} diff --git a/samples/integration/rest-api-cors/src/Echo.ts b/samples/integration/rest-api-cors/src/Echo.ts new file mode 100644 index 0000000..b33bbd6 --- /dev/null +++ b/samples/integration/rest-api-cors/src/Echo.ts @@ -0,0 +1,15 @@ +const handler = async (event) => { + return { + statusCode: 200, + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + queryStringParameters: event.queryStringParameters, + event: event, + env: process.env, + }), + } +} + +export { handler }; diff --git a/samples/integration/rest-api-cors/src/EchoCallback.ts b/samples/integration/rest-api-cors/src/EchoCallback.ts new file mode 100644 index 0000000..56d7fbb --- /dev/null +++ b/samples/integration/rest-api-cors/src/EchoCallback.ts @@ -0,0 +1,19 @@ +import { DoPromise } from "./Utils"; + +const handler = (event, context, callback) => { + DoPromise(2000).then(() => { + callback(null, { + statusCode: 200, + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + queryStringParameters: event.queryStringParameters, + event: event, + context: context, + }), + }) + }) +} + +export { handler }; diff --git a/samples/integration/rest-api-cors/src/Utils.ts b/samples/integration/rest-api-cors/src/Utils.ts new file mode 100644 index 0000000..54c2b0a --- /dev/null +++ b/samples/integration/rest-api-cors/src/Utils.ts @@ -0,0 +1,7 @@ +export async function DoPromise(timeout): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve(true); + }, timeout); + }) +} diff --git a/samples/simple/.env.sample b/samples/simple/.env.sample deleted file mode 100644 index 5f4a76c..0000000 --- a/samples/simple/.env.sample +++ /dev/null @@ -1,2 +0,0 @@ -ENV_FILE_VAL=value-from-env-file -ENV_FILE_OVERRIDE=overridden-value \ No newline at end of file diff --git a/samples/simple/package.json b/samples/simple/package.json deleted file mode 100644 index 01807f6..0000000 --- a/samples/simple/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "name": "terrable-sample-simple-api" -} \ No newline at end of file diff --git a/samples/simple/simple-api.tf b/samples/simple/simple-api.tf deleted file mode 100644 index 5ddc97e..0000000 --- a/samples/simple/simple-api.tf +++ /dev/null @@ -1,92 +0,0 @@ -provider "aws" { - region = "eu-west-2" -} - -terraform { - required_version = ">= 1.9.0" -} - -resource "aws_sqs_queue" "test_queue" { - name = "test-queue" -} - -module "simple_api" { - source = "terrable-dev/terrable-api/aws" - api_name = "simple-api" - - environment_variables = { - GLOBAL_ENV = "global-env-var" - } - - timeout = 3 - runtime = "nodejs20.x" - - rest_api = { - endpoint_type = "REGIONAL" - } - - handlers = { - EchoHandler: { - source = "./src/Echo.ts" - http = { - GET = "/", - POST = "/", - PUT = "/", - } - }, - - # Echo Handler configured with a callback-style instead of async / await - EchoCallback: { - source = "./src/EchoCallback.ts" - http = { - GET = "/echo-callback" - } - }, - - # Echo Handler with some variables that should be overwritten by the .env file - EchoEnvTest: { - source = "./src/Echo.ts" - http = { - GET = "/echo-env-test" - } - }, - - DelayedHandler: { - source = "./src/Delayed.ts" - http = { - GET = "/delayed" - } - }, - - TimeoutDelay: { - timeout = 1 - source = "./src/TimeoutDelay.ts" - http = { - GET = "/timeout" - } - }, - - SqsHandler: { - source = "./src/Sqs.ts" - sqs = { - queue = aws_sqs_queue.test_queue.arn - } - }, - - # These two handlers deliberately share a source file with the same name to verify - # they do not collide when transpiled into a "Collision.js" file - - CollisionOne: { - source = "./src/Collision1/Collision.ts" - http = { - GET = "/collision1" - } - }, - CollisionTwo: { - source = "./src/Collision2/Collision.ts" - http = { - GET = "/collision2" - } - } - } -} diff --git a/samples/simple/src/Sqs.ts b/samples/simple/src/Sqs.ts deleted file mode 100644 index f4c2d34..0000000 --- a/samples/simple/src/Sqs.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { DoPromise } from "./Utils"; - -const handler = async (event) => { - console.log('SQS: ', JSON.stringify(event)); - await DoPromise(300); -} - -export { handler }; \ No newline at end of file diff --git a/samples/simple/src/TimeoutDelay.ts b/samples/simple/src/TimeoutDelay.ts deleted file mode 100644 index 8f3ea1b..0000000 --- a/samples/simple/src/TimeoutDelay.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { DoPromise } from "./Utils"; - -// Configured in simple-api.tf to time out after 1 second -// This will cause a timeout error, which can be caught by -// the hurl tests to verify the timout logic - -const handler = async (event) => { - await DoPromise(2000); - - return { - statusCode: 200, - } -} - -export { handler }; \ No newline at end of file diff --git a/terrable_build b/terrable_build index 2c2e27f..d2a494d 100644 --- a/terrable_build +++ b/terrable_build @@ -1 +1 @@ -version = 0.11.0 \ No newline at end of file +version = 0.12.0 diff --git a/tests/_start.sh b/tests/_start.sh deleted file mode 100644 index 580734e..0000000 --- a/tests/_start.sh +++ /dev/null @@ -1,2 +0,0 @@ -go build ../ -./terrable offline -f "../samples/simple/simple-api.tf" -m "simple_api" -p "8081" -envfile "../samples/simple/.env.sample" \ No newline at end of file diff --git a/tests/offline_e2e_test.go b/tests/offline_e2e_test.go new file mode 100644 index 0000000..64f8cc1 --- /dev/null +++ b/tests/offline_e2e_test.go @@ -0,0 +1,588 @@ +//go:build e2e + +package tests + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "sync" + "testing" + "time" +) + +const serverStartupTimeout = 60 * time.Second + +var testServerInstance *testServer +var builtBinary *builtBinaryInfo + +type testServer struct { + baseURL string + cmd *exec.Cmd + output *safeBuffer + waitCh chan error +} + +type safeBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +type builtBinaryInfo struct { + path string + tempDir string +} + +type readinessCheck struct { + method string + path string + expectedStatus int + body string + headers map[string]string +} + +func (b *safeBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *safeBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +func TestMain(m *testing.M) { + binary, err := buildTestBinary() + if err != nil { + fmt.Fprintf(os.Stderr, "failed to build offline test binary: %v\n", err) + os.Exit(1) + } + + builtBinary = binary + + code := m.Run() + + if err := os.RemoveAll(binary.tempDir); err != nil { + fmt.Fprintf(os.Stderr, "failed to clean up offline test binary: %v\n", err) + code = 1 + } + + os.Exit(code) +} + +func TestOfflineCoreRequests(t *testing.T) { + withTestServer(t, "samples/integration/core/offline.tf", "offline_core", "samples/integration/core/.env.sample", []readinessCheck{ + {method: http.MethodGet, path: "/", expectedStatus: http.StatusOK}, + {method: http.MethodPost, path: "/", expectedStatus: http.StatusOK}, + {method: http.MethodGet, path: "/echo-env-test", expectedStatus: http.StatusOK}, + {method: http.MethodGet, path: "/collision1", expectedStatus: http.StatusOK}, + {method: http.MethodPost, path: "/_sqs/SqsHandler", expectedStatus: http.StatusOK, body: "readiness"}, + }, func() { + t.Run("echo GET request", func(t *testing.T) { + response := mustRequest(t, http.MethodGet, "/", nil, nil) + + response.assertStatus(t, http.StatusOK) + response.assertHeader(t, "Content-Type", "application/json") + response.assertJSONValue(t, "event.httpMethod", "GET") + }) + + t.Run("echo POST request", func(t *testing.T) { + response := mustRequest(t, http.MethodPost, "/", nil, nil) + + response.assertStatus(t, http.StatusOK) + response.assertJSONValue(t, "event.httpMethod", "POST") + }) + + t.Run("returns 404 for unsupported method", func(t *testing.T) { + response := mustRequest(t, http.MethodDelete, "/", nil, nil) + + response.assertStatus(t, http.StatusNotFound) + response.assertJSONValue(t, "message", "Not Found") + }) + + t.Run("returns 404 for missing route", func(t *testing.T) { + response := mustRequest(t, http.MethodGet, "/missing-route-"+strconv.FormatInt(time.Now().UnixNano(), 10), nil, nil) + + response.assertStatus(t, http.StatusNotFound) + response.assertJSONValue(t, "message", "Not Found") + }) + + t.Run("includes global environment variables", func(t *testing.T) { + response := mustRequest(t, http.MethodGet, "/", nil, nil) + + response.assertStatus(t, http.StatusOK) + response.assertJSONValue(t, "env.GLOBAL_ENV", "global-env-var") + }) + + t.Run("applies env file overrides", func(t *testing.T) { + response := mustRequest(t, http.MethodGet, "/echo-env-test", nil, nil) + + response.assertStatus(t, http.StatusOK) + response.assertJSONValue(t, "env.ENV_FILE_VAL", "value-from-env-file") + response.assertJSONValue(t, "env.ENV_FILE_OVERRIDE", "overridden-value") + }) + + t.Run("passes query string parameters", func(t *testing.T) { + response := mustRequest(t, http.MethodGet, "/?firstQuery=123&secondQuery=hello", nil, nil) + + response.assertStatus(t, http.StatusOK) + response.assertJSONValue(t, "queryStringParameters.firstQuery", "123") + response.assertJSONValue(t, "queryStringParameters.secondQuery", "hello") + }) + + t.Run("supports callback handlers", func(t *testing.T) { + response := mustRequest(t, http.MethodGet, "/echo-callback", nil, nil) + response.assertStatus(t, http.StatusOK) + }) + + t.Run("sets standard response headers", func(t *testing.T) { + response := mustRequest(t, http.MethodGet, "/", nil, nil) + + response.assertStatus(t, http.StatusOK) + response.assertHeader(t, "Content-Type", "application/json") + }) + + t.Run("returns delayed response and timing metadata", func(t *testing.T) { + response := mustRequest(t, http.MethodGet, "/delayed", nil, nil) + + response.assertStatus(t, http.StatusOK) + response.assertJSONNumberAtLeast(t, "time", 150) + + if response.duration < 150*time.Millisecond { + t.Fatalf("expected delayed request to take at least 150ms, took %s", response.duration) + } + }) + + t.Run("builds an SQS-style event for queue handlers", func(t *testing.T) { + response := mustRequest(t, http.MethodPost, "/_sqs/SqsHandler", nil, strings.NewReader("hello queue")) + + response.assertStatus(t, http.StatusOK) + response.assertHeader(t, "Content-Type", "application/json") + response.assertJSONNumberAtLeast(t, "recordCount", 1) + response.assertJSONValue(t, "firstRecord.body", "hello queue") + response.assertJSONValue(t, "firstRecord.eventSource", "aws:sqs") + response.assertJSONValue(t, "firstRecord.eventSourceARN", "arn:aws:sqs:eu-west-1:000000000000:SqsHandler") + response.assertJSONValue(t, "firstRecord.awsRegion", "eu-west-1") + response.assertJSONValue(t, "firstRecord.approximateReceiveCount", "1") + }) + + t.Run("timeout request does not break later requests", func(t *testing.T) { + timeoutResponse := mustRequest(t, http.MethodGet, "/timeout", nil, nil) + timeoutResponse.assertStatus(t, http.StatusGatewayTimeout) + + followUpResponse := mustRequest(t, http.MethodGet, "/", nil, nil) + followUpResponse.assertStatus(t, http.StatusOK) + }) + + t.Run("avoids handler collisions for same source file names", func(t *testing.T) { + firstResponse := mustRequest(t, http.MethodGet, "/collision1", nil, nil) + firstResponse.assertStatus(t, http.StatusOK) + firstResponse.assertJSONValue(t, "collision", "1") + + secondResponse := mustRequest(t, http.MethodGet, "/collision2", nil, nil) + secondResponse.assertStatus(t, http.StatusOK) + secondResponse.assertJSONValue(t, "collision", "2") + }) + }) +} + +func TestOfflineRESTAPICORSRequests(t *testing.T) { + withTestServer(t, "samples/integration/rest-api-cors/offline.tf", "rest_api_cors", "", []readinessCheck{ + {method: http.MethodGet, path: "/", expectedStatus: http.StatusOK}, + { + method: http.MethodOptions, + path: "/echo-callback", + expectedStatus: http.StatusNoContent, + headers: map[string]string{ + "Origin": "https://app.example.com", + "Access-Control-Request-Method": "GET", + }, + }, + }, func() { + t.Run("applies CORS response headers", func(t *testing.T) { + headers := map[string]string{ + "Origin": "https://app.example.com", + } + + response := mustRequest(t, http.MethodGet, "/", headers, nil) + + response.assertStatus(t, http.StatusOK) + response.assertHeader(t, "Content-Type", "application/json") + response.assertHeader(t, "Access-Control-Allow-Origin", "https://app.example.com") + response.assertHeader(t, "Access-Control-Allow-Credentials", "true") + response.assertHeader(t, "Access-Control-Expose-Headers", "x-terrable-request-id") + response.assertHeader(t, "Vary", "Origin") + }) + + t.Run("applies implicit CORS OPTIONS headers on root", func(t *testing.T) { + headers := map[string]string{ + "Origin": "https://app.example.com", + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "content-type,authorization", + } + + response := mustRequest(t, http.MethodOptions, "/", headers, nil) + + response.assertStatus(t, http.StatusNoContent) + response.assertHeader(t, "Access-Control-Allow-Origin", "https://app.example.com") + response.assertHeader(t, "Access-Control-Allow-Methods", "GET, OPTIONS, POST, PUT") + response.assertHeader(t, "Access-Control-Allow-Headers", "content-type, authorization") + response.assertHeader(t, "Access-Control-Allow-Credentials", "true") + response.assertHeader(t, "Access-Control-Max-Age", "600") + response.assertHeader(t, "Vary", "Origin") + }) + + t.Run("applies implicit CORS OPTIONS headers on callback route", func(t *testing.T) { + headers := map[string]string{ + "Origin": "https://app.example.com", + "Access-Control-Request-Method": "GET", + } + + response := mustRequest(t, http.MethodOptions, "/echo-callback", headers, nil) + + response.assertStatus(t, http.StatusNoContent) + response.assertHeader(t, "Access-Control-Allow-Origin", "https://app.example.com") + response.assertHeader(t, "Access-Control-Allow-Methods", "GET, OPTIONS, POST, PUT") + response.assertHeader(t, "Access-Control-Allow-Headers", "content-type, authorization") + response.assertHeader(t, "Access-Control-Allow-Credentials", "true") + response.assertHeader(t, "Access-Control-Max-Age", "600") + response.assertHeader(t, "Vary", "Origin") + }) + }) +} + +type httpResponse struct { + statusCode int + headers http.Header + body []byte + duration time.Duration +} + +func buildTestBinary() (*builtBinaryInfo, error) { + rootDir, err := repoRoot() + if err != nil { + return nil, err + } + + tempDir, err := os.MkdirTemp("", "terrable-e2e-*") + if err != nil { + return nil, err + } + + binaryPath := filepath.Join(tempDir, "terrable"+exeSuffix()) + buildCommand := exec.Command("go", "build", "-o", binaryPath, ".") + buildCommand.Dir = rootDir + buildOutput, err := buildCommand.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("go build failed: %w\n%s", err, string(buildOutput)) + } + + return &builtBinaryInfo{ + path: binaryPath, + tempDir: tempDir, + }, nil +} + +func withTestServer(t *testing.T, configPath, moduleName, envFilePath string, checks []readinessCheck, fn func()) { + t.Helper() + + server, err := startTestServer(configPath, moduleName, envFilePath, checks) + if err != nil { + t.Fatalf("failed to start offline test server: %v", err) + } + + testServerInstance = server + defer func() { + testServerInstance = nil + if err := server.Stop(); err != nil { + t.Fatalf("failed to stop offline test server cleanly: %v", err) + } + }() + + fn() +} + +func startTestServer(configPath, moduleName, envFilePath string, checks []readinessCheck) (*testServer, error) { + rootDir, err := repoRoot() + if err != nil { + return nil, err + } + + port, err := reservePort() + if err != nil { + return nil, err + } + + serverOutput := &safeBuffer{} + args := []string{ + "offline", + "-f", filepath.Join(rootDir, configPath), + "-m", moduleName, + "-p", strconv.Itoa(port), + "--node-debug-port", "0", + } + + if envFilePath != "" { + args = append(args, "-envfile", filepath.Join(rootDir, envFilePath)) + } + + command := exec.Command(builtBinary.path, args...) + command.Dir = rootDir + + stdout, err := command.StdoutPipe() + if err != nil { + return nil, err + } + + stderr, err := command.StderrPipe() + if err != nil { + return nil, err + } + + if err := command.Start(); err != nil { + return nil, err + } + + go io.Copy(serverOutput, stdout) + go io.Copy(serverOutput, stderr) + + waitCh := make(chan error, 1) + go func() { + waitCh <- command.Wait() + }() + + server := &testServer{ + baseURL: fmt.Sprintf("http://127.0.0.1:%d", port), + cmd: command, + output: serverOutput, + waitCh: waitCh, + } + + if err := waitForServer(server, checks, serverStartupTimeout); err != nil { + _ = server.Stop() + return nil, err + } + + return server, nil +} + +func (s *testServer) Stop() error { + if s == nil || s.cmd == nil || s.cmd.Process == nil { + return nil + } + + if s.cmd.ProcessState == nil || !s.cmd.ProcessState.Exited() { + _ = s.cmd.Process.Kill() + } + + select { + case err := <-s.waitCh: + if err != nil && !errors.Is(err, os.ErrProcessDone) && !strings.Contains(err.Error(), "killed") { + return fmt.Errorf("process exited unexpectedly: %w\nserver output:\n%s", err, s.output.String()) + } + return nil + case <-time.After(5 * time.Second): + return fmt.Errorf("timed out waiting for offline process to stop\nserver output:\n%s", s.output.String()) + } +} + +func waitForServer(server *testServer, checks []readinessCheck, timeout time.Duration) error { + client := &http.Client{Timeout: 2 * time.Second} + deadline := time.Now().Add(timeout) + + for time.Now().Before(deadline) { + select { + case err := <-server.waitCh: + return fmt.Errorf("offline process exited before becoming ready: %w\nserver output:\n%s", err, server.output.String()) + default: + } + + ready, err := runReadinessChecks(client, server.baseURL, checks) + if err == nil && ready { + return nil + } + + time.Sleep(250 * time.Millisecond) + } + + return fmt.Errorf("offline server did not become ready within %s\nserver output:\n%s", timeout, server.output.String()) +} + +func runReadinessChecks(client *http.Client, baseURL string, checks []readinessCheck) (bool, error) { + for _, check := range checks { + var body io.Reader + if check.body != "" { + body = strings.NewReader(check.body) + } + + request, err := http.NewRequest(check.method, baseURL+check.path, body) + if err != nil { + return false, err + } + + for key, value := range check.headers { + request.Header.Set(key, value) + } + + response, err := client.Do(request) + if err != nil { + return false, nil + } + response.Body.Close() + + if response.StatusCode != check.expectedStatus { + return false, nil + } + } + + return true, nil +} + +func mustRequest(t *testing.T, method, path string, headers map[string]string, body io.Reader) httpResponse { + t.Helper() + + request, err := http.NewRequest(method, testServerInstance.baseURL+path, body) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + + for key, value := range headers { + request.Header.Set(key, value) + } + + client := &http.Client{Timeout: 10 * time.Second} + start := time.Now() + response, err := client.Do(request) + if err != nil { + t.Fatalf("request failed: %v\nserver output:\n%s", err, testServerInstance.output.String()) + } + defer response.Body.Close() + + responseBody, err := io.ReadAll(response.Body) + if err != nil { + t.Fatalf("failed to read response body: %v", err) + } + + return httpResponse{ + statusCode: response.StatusCode, + headers: response.Header.Clone(), + body: responseBody, + duration: time.Since(start), + } +} + +func (r httpResponse) assertStatus(t *testing.T, expected int) { + t.Helper() + + if r.statusCode != expected { + t.Fatalf("expected status %d, got %d. body=%s", expected, r.statusCode, string(r.body)) + } +} + +func (r httpResponse) assertHeader(t *testing.T, name, expected string) { + t.Helper() + + value := r.headers.Get(name) + if value != expected { + t.Fatalf("expected header %s=%q, got %q", name, expected, value) + } +} + +func (r httpResponse) assertJSONValue(t *testing.T, path, expected string) { + t.Helper() + + value, err := r.jsonValue(path) + if err != nil { + t.Fatal(err) + } + + stringValue, ok := value.(string) + if !ok { + t.Fatalf("expected %s to be a string, got %T", path, value) + } + + if stringValue != expected { + t.Fatalf("expected %s=%q, got %q", path, expected, stringValue) + } +} + +func (r httpResponse) assertJSONNumberAtLeast(t *testing.T, path string, minimum float64) { + t.Helper() + + value, err := r.jsonValue(path) + if err != nil { + t.Fatal(err) + } + + numberValue, ok := value.(float64) + if !ok { + t.Fatalf("expected %s to be a number, got %T", path, value) + } + + if numberValue < minimum { + t.Fatalf("expected %s >= %v, got %v", path, minimum, numberValue) + } +} + +func (r httpResponse) jsonValue(path string) (interface{}, error) { + var payload map[string]interface{} + if err := json.Unmarshal(r.body, &payload); err != nil { + return nil, fmt.Errorf("failed to parse JSON body %q: %w", string(r.body), err) + } + + var current interface{} = payload + for _, part := range strings.Split(path, ".") { + object, ok := current.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("path %s did not resolve to an object at %q", path, part) + } + + next, ok := object[part] + if !ok { + return nil, fmt.Errorf("path %s missing key %q", path, part) + } + + current = next + } + + return current, nil +} + +func reservePort() (int, error) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return 0, err + } + defer listener.Close() + + return listener.Addr().(*net.TCPAddr).Port, nil +} + +func repoRoot() (string, error) { + _, currentFile, _, ok := runtime.Caller(0) + if !ok { + return "", fmt.Errorf("could not determine current file path") + } + + return filepath.Clean(filepath.Join(filepath.Dir(currentFile), "..")), nil +} + +func exeSuffix() string { + if runtime.GOOS == "windows" { + return ".exe" + } + + return "" +} diff --git a/tests/requests/callback_test.hurl b/tests/requests/callback_test.hurl deleted file mode 100644 index d8e61ac..0000000 --- a/tests/requests/callback_test.hurl +++ /dev/null @@ -1,5 +0,0 @@ -# Invoked an endpoint that uses a 'callback' (non async / await) -# style of handler. - -GET http://127.0.0.1:8081/echo-callback -HTTP 200 diff --git a/tests/requests/collision_test.hurl b/tests/requests/collision_test.hurl deleted file mode 100644 index ca55bc3..0000000 --- a/tests/requests/collision_test.hurl +++ /dev/null @@ -1,16 +0,0 @@ -# Ensures that two handlers configured with the same source file name -# work as intended and do not have any collisions when transpiled - -GET http://127.0.0.1:8081/collision1 -HTTP 200 -[Captures] -response_body: body -[Asserts] -jsonpath "$.collision" == "1" - -GET http://127.0.0.1:8081/collision2 -HTTP 200 -[Captures] -response_body: body -[Asserts] -jsonpath "$.collision" == "2" diff --git a/tests/requests/delayed.hurl b/tests/requests/delayed.hurl deleted file mode 100644 index fc46572..0000000 --- a/tests/requests/delayed.hurl +++ /dev/null @@ -1,6 +0,0 @@ -GET http://127.0.0.1:8081/delayed - -HTTP 200 -[Asserts] -jsonpath "$.time" >= 150 -duration >= 150 \ No newline at end of file diff --git a/tests/requests/echo_bad_method.hurl b/tests/requests/echo_bad_method.hurl deleted file mode 100644 index 4434be3..0000000 --- a/tests/requests/echo_bad_method.hurl +++ /dev/null @@ -1,7 +0,0 @@ -# Hit a route that exists but not for the method in the request -DELETE http://127.0.0.1:8081/ -HTTP 404 - -[Asserts] -jsonpath "$.message" == "Not Found" - \ No newline at end of file diff --git a/tests/requests/echo_get.hurl b/tests/requests/echo_get.hurl deleted file mode 100644 index 96ebef9..0000000 --- a/tests/requests/echo_get.hurl +++ /dev/null @@ -1,5 +0,0 @@ -GET http://127.0.0.1:8081/ -HTTP 200 - -[Asserts] -jsonpath "$.event.httpMethod" == "GET" diff --git a/tests/requests/echo_no_route.hurl b/tests/requests/echo_no_route.hurl deleted file mode 100644 index 24aee65..0000000 --- a/tests/requests/echo_no_route.hurl +++ /dev/null @@ -1,6 +0,0 @@ -# Hit a route that doesn't exist at all -GET http://127.0.0.1:8081/{{newUuid}} -HTTP 404 - -[Asserts] -jsonpath "$.message" == "Not Found" diff --git a/tests/requests/echo_post.hurl b/tests/requests/echo_post.hurl deleted file mode 100644 index 952a051..0000000 --- a/tests/requests/echo_post.hurl +++ /dev/null @@ -1,5 +0,0 @@ -POST http://127.0.0.1:8081/ -HTTP 200 - -[Asserts] -jsonpath "$.event.httpMethod" == "POST" diff --git a/tests/requests/env_file_overrides.hurl b/tests/requests/env_file_overrides.hurl deleted file mode 100644 index f5c9f64..0000000 --- a/tests/requests/env_file_overrides.hurl +++ /dev/null @@ -1,6 +0,0 @@ -GET http://127.0.0.1:8081/echo-env-test -HTTP 200 - -[Asserts] -jsonpath "$.env.ENV_FILE_VAL" == "value-from-env-file" -jsonpath "$.env.ENV_FILE_OVERRIDE" == "overridden-value" diff --git a/tests/requests/env_vars.hurl b/tests/requests/env_vars.hurl deleted file mode 100644 index c53d226..0000000 --- a/tests/requests/env_vars.hurl +++ /dev/null @@ -1,5 +0,0 @@ -GET http://127.0.0.1:8081/ -HTTP 200 - -[Asserts] -jsonpath "$.env.GLOBAL_ENV" == "global-env-var" diff --git a/tests/requests/query_string_parameters.hurl b/tests/requests/query_string_parameters.hurl deleted file mode 100644 index 87bd1b6..0000000 --- a/tests/requests/query_string_parameters.hurl +++ /dev/null @@ -1,6 +0,0 @@ -GET http://127.0.0.1:8081/?firstQuery=123&secondQuery=hello -HTTP 200 - -[Asserts] -jsonpath "$.queryStringParameters.firstQuery" == "123" -jsonpath "$.queryStringParameters.secondQuery" == "hello" diff --git a/tests/requests/response_headers.hurl b/tests/requests/response_headers.hurl deleted file mode 100644 index 151c315..0000000 --- a/tests/requests/response_headers.hurl +++ /dev/null @@ -1,5 +0,0 @@ -GET http://127.0.0.1:8081/ -HTTP 200 - -[Asserts] -header "Content-Type" == "application/json" diff --git a/tests/requests/sqs_test.hurl b/tests/requests/sqs_test.hurl deleted file mode 100644 index ff82175..0000000 --- a/tests/requests/sqs_test.hurl +++ /dev/null @@ -1,7 +0,0 @@ -# Tests that the SQS Handler endpoint is exposed and executes - -POST http://127.0.0.1:8081/_sqs/SqsHandler -HTTP 200 - -[Asserts] -duration >= 300 diff --git a/tests/requests/timeout_test.hurl b/tests/requests/timeout_test.hurl deleted file mode 100644 index 9cbb052..0000000 --- a/tests/requests/timeout_test.hurl +++ /dev/null @@ -1,7 +0,0 @@ -# Tests that the SQS Handler endpoint is exposed and executes - -GET http://127.0.0.1:8081/timeout -HTTP 504 - -GET http://127.0.0.1:8081/ -HTTP 200 diff --git a/utils/terraform.go b/utils/terraform.go index 9b523b0..5f81daf 100644 --- a/utils/terraform.go +++ b/utils/terraform.go @@ -92,6 +92,8 @@ func ParseModuleConfiguration(filename string, moduleBlock *hcl.Block) (*config. Attributes: []hcl.AttributeSchema{ {Name: "handlers", Required: false}, {Name: "environment_variables", Required: false}, + {Name: "http_api", Required: false}, + {Name: "rest_api", Required: false}, {Name: "timeout", Required: false}, }, }) @@ -122,6 +124,34 @@ func ParseModuleConfiguration(filename string, moduleBlock *hcl.Block) (*config. } } + if httpAPI, ok := moduleContent.Attributes["http_api"]; ok { + httpAPIValue, diags := httpAPI.Expr.Value(nil) + if diags.HasErrors() { + return nil, fmt.Errorf("error parsing http_api configuration: %s", diags.Error()) + } + + parsedHTTPAPI, err := parseAPIGatewayConfig(httpAPIValue) + if err != nil { + return nil, fmt.Errorf("error parsing http_api configuration: %w", err) + } + + terrableConfig.HttpApi = parsedHTTPAPI + } + + if restAPI, ok := moduleContent.Attributes["rest_api"]; ok { + restAPIValue, diags := restAPI.Expr.Value(nil) + if diags.HasErrors() { + return nil, fmt.Errorf("error parsing rest_api configuration: %s", diags.Error()) + } + + parsedRESTAPI, err := parseAPIGatewayConfig(restAPIValue) + if err != nil { + return nil, fmt.Errorf("error parsing rest_api configuration: %w", err) + } + + terrableConfig.RestApi = parsedRESTAPI + } + if handlers, ok := moduleContent.Attributes["handlers"]; ok { handlersValue, _ := handlers.Expr.Value(nil) handlerMap := handlersValue.AsValueMap() @@ -177,6 +207,119 @@ func ParseModuleConfiguration(filename string, moduleBlock *hcl.Block) (*config. return &terrableConfig, nil } +func parseAPIGatewayConfig(apiConfig cty.Value) (*config.APIGatewayConfig, error) { + if apiConfig.IsNull() { + return nil, nil + } + + parsedConfig := &config.APIGatewayConfig{} + apiConfigMap := apiConfig.AsValueMap() + + corsConfig, ok := apiConfigMap["cors_configuration"] + if !ok { + corsConfig, ok = apiConfigMap["cors"] + } + + if ok && !corsConfig.IsNull() { + parsedCORSConfig, err := parseCorsConfig(corsConfig) + if err != nil { + return nil, err + } + + parsedConfig.Cors = parsedCORSConfig + } + + return parsedConfig, nil +} + +func parseCorsConfig(corsConfig cty.Value) (*config.CorsConfig, error) { + if corsConfig.IsNull() { + return nil, nil + } + + parsedConfig := &config.CorsConfig{} + corsConfigMap := corsConfig.AsValueMap() + + if allowOrigins, ok := corsConfigMap["allow_origins"]; ok { + parsedAllowOrigins, err := parseStringList(allowOrigins, "allow_origins") + if err != nil { + return nil, err + } + + parsedConfig.AllowOrigins = parsedAllowOrigins + } + + if allowMethods, ok := corsConfigMap["allow_methods"]; ok { + parsedAllowMethods, err := parseStringList(allowMethods, "allow_methods") + if err != nil { + return nil, err + } + + parsedConfig.AllowMethods = parsedAllowMethods + } + + if allowHeaders, ok := corsConfigMap["allow_headers"]; ok { + parsedAllowHeaders, err := parseStringList(allowHeaders, "allow_headers") + if err != nil { + return nil, err + } + + parsedConfig.AllowHeaders = parsedAllowHeaders + } + + if exposeHeaders, ok := corsConfigMap["expose_headers"]; ok { + parsedExposeHeaders, err := parseStringList(exposeHeaders, "expose_headers") + if err != nil { + return nil, err + } + + parsedConfig.ExposeHeaders = parsedExposeHeaders + } + + if allowCredentials, ok := corsConfigMap["allow_credentials"]; ok { + if allowCredentials.Type() != cty.Bool { + return nil, fmt.Errorf("allow_credentials must be a boolean") + } + + parsedConfig.AllowCredentials = allowCredentials.True() + } + + if maxAge, ok := corsConfigMap["max_age"]; ok { + if maxAge.Type() != cty.Number { + return nil, fmt.Errorf("max_age must be a number") + } + + parsedMaxAge, _ := maxAge.AsBigFloat().Int64() + parsedConfig.MaxAge = int(parsedMaxAge) + } + + return parsedConfig, nil +} + +func parseStringList(value cty.Value, fieldName string) ([]string, error) { + if value.IsNull() { + return nil, nil + } + + if !value.CanIterateElements() { + return nil, fmt.Errorf("%s must be a list of strings", fieldName) + } + + var values []string + iterator := value.ElementIterator() + + for iterator.Next() { + _, element := iterator.Element() + if element.Type() != cty.String { + return nil, fmt.Errorf("%s must be a list of strings", fieldName) + } + + values = append(values, element.AsString()) + } + + return values, nil +} + func getAbsoluteHandlerSourcePath(basePath string, sourcePath string) (string, error) { if filepath.IsAbs(sourcePath) { return sourcePath, nil diff --git a/utils/terraform_test.go b/utils/terraform_test.go index 0da1adb..39db3bd 100644 --- a/utils/terraform_test.go +++ b/utils/terraform_test.go @@ -10,11 +10,13 @@ import ( func TestParseModuleConfiguration(t *testing.T) { tests := []struct { - name string - hclContent string - wantGlobalTimeout int - wantHandlerTimeout int - wantErr bool + name string + hclContent string + wantGlobalTimeout int + wantHandlerTimeout int + wantHTTPCORSOrigins []string + wantRESTCORSOrigins []string + wantErr bool }{ { name: "uses default timeout when not specified", @@ -73,6 +75,61 @@ func TestParseModuleConfiguration(t *testing.T) { wantHandlerTimeout: 10, wantErr: false, }, + { + name: "parses HTTP API cors_configuration", + hclContent: ` + module "test" { + http_api = { + cors_configuration = { + allow_origins = ["https://app.example.com"] + allow_methods = ["GET", "POST"] + allow_headers = ["content-type"] + expose_headers = ["x-request-id"] + allow_credentials = true + max_age = 600 + } + } + + handlers = { + TestHandler = { + source = "./test.ts" + http = { + GET = "/test" + } + } + } + } + `, + wantGlobalTimeout: DefaultTimeout, + wantHandlerTimeout: DefaultTimeout, + wantHTTPCORSOrigins: []string{"https://app.example.com"}, + wantErr: false, + }, + { + name: "parses REST API cors shorthand", + hclContent: ` + module "test" { + rest_api = { + cors = { + allow_origins = ["https://example.com"] + } + } + + handlers = { + TestHandler = { + source = "./test.ts" + http = { + GET = "/test" + } + } + } + } + `, + wantGlobalTimeout: DefaultTimeout, + wantHandlerTimeout: DefaultTimeout, + wantRESTCORSOrigins: []string{"https://example.com"}, + wantErr: false, + }, { name: "invalid timeout value returns error", hclContent: ` @@ -120,6 +177,18 @@ func TestParseModuleConfiguration(t *testing.T) { if len(config.Handlers) > 0 { assert.Equal(t, tt.wantHandlerTimeout, config.Handlers[0].Timeout, "Handler timeout mismatch") } + + if len(tt.wantHTTPCORSOrigins) > 0 { + if assert.NotNil(t, config.HttpApi) && assert.NotNil(t, config.HttpApi.Cors) { + assert.Equal(t, tt.wantHTTPCORSOrigins, config.HttpApi.Cors.AllowOrigins) + } + } + + if len(tt.wantRESTCORSOrigins) > 0 { + if assert.NotNil(t, config.RestApi) && assert.NotNil(t, config.RestApi.Cors) { + assert.Equal(t, tt.wantRESTCORSOrigins, config.RestApi.Cors.AllowOrigins) + } + } }) } }