Skip to content

Commit 3b1431e

Browse files
committed
feat: refactor to unified request handler
1 parent 5c7d29d commit 3b1431e

File tree

10 files changed

+306
-242
lines changed

10 files changed

+306
-242
lines changed

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010
- `cmd/verify/main.go` - Integration tests with HTTP mock servers
1111
- `cmd/sample/main.go` - **DO NOT RUN** (requires live credentials)
1212
- **Packages**:
13-
- `internal/app/` - Core logic, configuration, and HTTP handlers (no AWS
14-
dependencies)
13+
- `internal/app/` - Core logic and unified request handling via
14+
`HandleRequest()` (no AWS dependencies)
1515
- `internal/github/` - API client, webhooks, PR checks, team mgmt, auth
1616
- `internal/okta/` - API client, group sync
1717
- `internal/notifiers/` - Slack formatting for events and reports

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,10 @@ APP_GITHUB_WEBHOOK_SECRET=arn:aws:ssm:us-east-1:123456789012:parameter/github-bo
122122

123123
### Other
124124

125-
| Variable | Description |
126-
|--------------------------|------------------------------------|
127-
| `APP_DEBUG_ENABLED` | Verbose logging (default: `false`) |
125+
| Variable | Description |
126+
|--------------------------|------------------------------------------------|
127+
| `APP_DEBUG_ENABLED` | Verbose logging (default: `false`) |
128+
| `APP_BASE_PATH` | URL prefix to strip (e.g., `/api/v1`) |
128129

129130
### Okta Sync Rules
130131

cmd/lambda/README.md

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ alternatives like standard HTTP servers.
66

77
## Overview
88

9-
The Lambda adapter translates AWS-specific events into standard application
10-
calls:
11-
- **API Gateway** (webhooks) → HTTP handlers
12-
- **EventBridge** (scheduled sync) → `ProcessScheduledEvent()`
9+
The Lambda adapter translates AWS-specific events into the unified
10+
`app.HandleRequest()` interface:
11+
- **API Gateway** (webhooks, status, config) → `app.Request{Type: HTTP}`
12+
- **EventBridge** (scheduled sync) → `app.Request{Type: Scheduled}`
1313

1414
## Build
1515

@@ -83,15 +83,19 @@ Create an EventBridge rule:
8383

8484
### Universal Handler
8585

86-
The Lambda function uses a universal handler that detects event types:
86+
The Lambda function uses a universal handler that detects event types and
87+
converts them to the unified `app.Request` format:
8788

8889
```go
8990
func UniversalHandler(ctx context.Context, event json.RawMessage) (any, error)
9091
```
9192

9293
**Supported Events**:
93-
- `APIGatewayV2HTTPRequest` → Routes to webhook/status/config handlers
94-
- `CloudWatchEvent` (EventBridge) → Routes to scheduled event processor
94+
- `APIGatewayV2HTTPRequest` → Converts to `app.Request{Type: HTTP}`
95+
- `CloudWatchEvent` (EventBridge) → Converts to `app.Request{Type: Scheduled}`
96+
97+
All requests are then processed by `app.HandleRequest()` which routes based on
98+
request type and path.
9599
96100
### Endpoints
97101

cmd/lambda/main.go

Lines changed: 30 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
// Package main provides the AWS Lambda entry point for the GitHub bot.
2-
// This Lambda handler supports API Gateway HTTP API (v2.0) and EventBridge
3-
// (scheduled sync) events.
41
package main
52

63
import (
@@ -15,7 +12,6 @@ import (
1512
"github.com/aws/aws-lambda-go/lambda"
1613
"github.com/cruxstack/github-ops-app/internal/app"
1714
"github.com/cruxstack/github-ops-app/internal/config"
18-
"github.com/cruxstack/github-ops-app/internal/github"
1915
)
2016

2117
var (
@@ -25,8 +21,6 @@ var (
2521
initErr error
2622
)
2723

28-
// initApp initializes the application instance once using sync.Once.
29-
// stores any initialization error in the initErr global variable.
3024
func initApp() {
3125
initOnce.Do(func() {
3226
logger = config.NewLogger()
@@ -40,9 +34,7 @@ func initApp() {
4034
})
4135
}
4236

43-
// APIGatewayHandler processes incoming API Gateway HTTP API (v2.0) requests.
44-
// handles GitHub webhook events, status checks, and config endpoints.
45-
// validates webhook signatures before processing events.
37+
// APIGatewayHandler converts API Gateway requests to unified app.Request.
4638
func APIGatewayHandler(ctx context.Context, req awsevents.APIGatewayV2HTTPRequest) (awsevents.APIGatewayV2HTTPResponse, error) {
4739
initApp()
4840
if initErr != nil {
@@ -66,108 +58,29 @@ func APIGatewayHandler(ctx context.Context, req awsevents.APIGatewayV2HTTPReques
6658
}
6759
}
6860

69-
if path == "/server/status" {
70-
return handleServerStatus(ctx, req)
61+
headers := make(map[string]string)
62+
for key, value := range req.Headers {
63+
headers[strings.ToLower(key)] = value
7164
}
7265

73-
if path == "/server/config" {
74-
return handleServerConfig(ctx, req)
66+
appReq := app.Request{
67+
Type: app.RequestTypeHTTP,
68+
Method: req.RequestContext.HTTP.Method,
69+
Path: path,
70+
Headers: headers,
71+
Body: []byte(req.Body),
7572
}
7673

77-
if req.RequestContext.HTTP.Method != "POST" {
78-
return awsevents.APIGatewayV2HTTPResponse{
79-
StatusCode: 405,
80-
Body: "method not allowed",
81-
}, nil
82-
}
83-
84-
eventType := req.Headers["x-github-event"]
85-
signature := req.Headers["x-hub-signature-256"]
86-
87-
if err := github.ValidateWebhookSignature(
88-
[]byte(req.Body),
89-
signature,
90-
appInst.Config.GitHubWebhookSecret,
91-
); err != nil {
92-
logger.Warn("webhook signature validation failed", slog.String("error", err.Error()))
93-
return awsevents.APIGatewayV2HTTPResponse{
94-
StatusCode: 401,
95-
Body: "unauthorized",
96-
}, nil
97-
}
98-
99-
if err := appInst.ProcessWebhook(ctx, []byte(req.Body), eventType); err != nil {
100-
logger.Error("webhook processing failed",
101-
slog.String("event_type", eventType),
102-
slog.String("error", err.Error()))
103-
return awsevents.APIGatewayV2HTTPResponse{
104-
StatusCode: 500,
105-
Body: "webhook processing failed",
106-
}, nil
107-
}
74+
resp := appInst.HandleRequest(ctx, appReq)
10875

10976
return awsevents.APIGatewayV2HTTPResponse{
110-
StatusCode: 200,
111-
Body: "ok",
77+
StatusCode: resp.StatusCode,
78+
Headers: resp.Headers,
79+
Body: string(resp.Body),
11280
}, nil
11381
}
11482

115-
// handleServerStatus returns the application status and feature flags.
116-
// responds with JSON containing configuration state and enabled features.
117-
func handleServerStatus(ctx context.Context, req awsevents.APIGatewayV2HTTPRequest) (awsevents.APIGatewayV2HTTPResponse, error) {
118-
if req.RequestContext.HTTP.Method != "GET" {
119-
return awsevents.APIGatewayV2HTTPResponse{
120-
StatusCode: 405,
121-
Body: "method not allowed",
122-
}, nil
123-
}
124-
125-
status := appInst.GetStatus()
126-
body, err := json.Marshal(status)
127-
if err != nil {
128-
logger.Error("failed to marshal status response", slog.String("error", err.Error()))
129-
return awsevents.APIGatewayV2HTTPResponse{
130-
StatusCode: 500,
131-
Body: "failed to generate status response",
132-
}, nil
133-
}
134-
135-
return awsevents.APIGatewayV2HTTPResponse{
136-
StatusCode: 200,
137-
Headers: map[string]string{"Content-Type": "application/json"},
138-
Body: string(body),
139-
}, nil
140-
}
141-
142-
// handleServerConfig returns the application configuration with secrets
143-
// redacted. useful for debugging and verifying environment settings.
144-
func handleServerConfig(ctx context.Context, req awsevents.APIGatewayV2HTTPRequest) (awsevents.APIGatewayV2HTTPResponse, error) {
145-
if req.RequestContext.HTTP.Method != "GET" {
146-
return awsevents.APIGatewayV2HTTPResponse{
147-
StatusCode: 405,
148-
Body: "method not allowed",
149-
}, nil
150-
}
151-
152-
redacted := appInst.Config.Redacted()
153-
body, err := json.Marshal(redacted)
154-
if err != nil {
155-
logger.Error("failed to marshal config response", slog.String("error", err.Error()))
156-
return awsevents.APIGatewayV2HTTPResponse{
157-
StatusCode: 500,
158-
Body: "failed to generate config response",
159-
}, nil
160-
}
161-
162-
return awsevents.APIGatewayV2HTTPResponse{
163-
StatusCode: 200,
164-
Headers: map[string]string{"Content-Type": "application/json"},
165-
Body: string(body),
166-
}, nil
167-
}
168-
169-
// EventBridgeHandler processes EventBridge scheduled events.
170-
// typically handles scheduled Okta group sync operations.
83+
// EventBridgeHandler converts EventBridge events to unified app.Request.
17184
func EventBridgeHandler(ctx context.Context, evt awsevents.CloudWatchEvent) error {
17285
initApp()
17386
if initErr != nil {
@@ -185,23 +98,33 @@ func EventBridgeHandler(ctx context.Context, evt awsevents.CloudWatchEvent) erro
18598
return err
18699
}
187100

188-
return appInst.ProcessScheduledEvent(ctx, detail)
101+
req := app.Request{
102+
Type: app.RequestTypeScheduled,
103+
ScheduledAction: detail.Action,
104+
ScheduledData: detail.Data,
105+
}
106+
107+
resp := appInst.HandleRequest(ctx, req)
108+
109+
if resp.StatusCode >= 400 {
110+
return fmt.Errorf("scheduled event failed: %s", string(resp.Body))
111+
}
112+
113+
return nil
189114
}
190115

191-
// UniversalHandler detects the event type and routes to the appropriate
192-
// handler. supports API Gateway HTTP API (v2.0) and EventBridge events.
116+
// UniversalHandler detects event type and routes to the appropriate handler.
193117
func UniversalHandler(ctx context.Context, event json.RawMessage) (any, error) {
118+
initApp()
194119
if initErr != nil {
195120
return nil, initErr
196121
}
197122

198-
// try API Gateway HTTP API (v2.0)
199123
var apiGatewayReq awsevents.APIGatewayV2HTTPRequest
200124
if err := json.Unmarshal(event, &apiGatewayReq); err == nil && apiGatewayReq.RequestContext.HTTP.Method != "" {
201125
return APIGatewayHandler(ctx, apiGatewayReq)
202126
}
203127

204-
// try EventBridge
205128
var eventBridgeEvent awsevents.CloudWatchEvent
206129
if err := json.Unmarshal(event, &eventBridgeEvent); err == nil && eventBridgeEvent.DetailType != "" {
207130
return nil, EventBridgeHandler(ctx, eventBridgeEvent)

cmd/server/main.go

Lines changed: 30 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
1-
// Package main provides a standard HTTP server entry point for the GitHub bot.
2-
// runs as a long-lived HTTP server suitable for deployment on any VPS, container,
3-
// or Kubernetes cluster.
41
package main
52

63
import (
74
"context"
8-
"encoding/json"
5+
"io"
96
"log/slog"
107
"net/http"
118
"os"
129
"os/signal"
10+
"strings"
1311
"syscall"
1412
"time"
1513

@@ -39,10 +37,7 @@ func main() {
3937
}
4038

4139
mux := http.NewServeMux()
42-
mux.HandleFunc("/webhooks", appInst.WebhookHandler)
43-
mux.HandleFunc("/server/status", appInst.StatusHandler)
44-
mux.HandleFunc("/server/config", appInst.ConfigHandler)
45-
mux.HandleFunc("/scheduled/okta-sync", scheduledOktaSyncHandler)
40+
mux.HandleFunc("/", httpHandler)
4641

4742
port := os.Getenv("PORT")
4843
if port == "" {
@@ -86,34 +81,41 @@ func main() {
8681
logger.Info("server stopped")
8782
}
8883

89-
// scheduledOktaSyncHandler handles HTTP-triggered Okta sync operations.
90-
// can be invoked by external cron services or schedulers.
91-
func scheduledOktaSyncHandler(w http.ResponseWriter, r *http.Request) {
92-
if r.Method != http.MethodPost {
93-
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
84+
// httpHandler converts http.Request to app.Request and handles the response.
85+
func httpHandler(w http.ResponseWriter, r *http.Request) {
86+
body, err := io.ReadAll(r.Body)
87+
if err != nil {
88+
http.Error(w, "failed to read request body", http.StatusBadRequest)
9489
return
9590
}
91+
defer r.Body.Close()
9692

97-
evt := app.ScheduledEvent{
98-
Action: "okta-sync",
93+
headers := make(map[string]string)
94+
for key, values := range r.Header {
95+
if len(values) > 0 {
96+
headers[strings.ToLower(key)] = values[0]
97+
}
9998
}
10099

101-
if err := appInst.ProcessScheduledEvent(r.Context(), evt); err != nil {
102-
logger.Error("scheduled event processing failed",
103-
slog.String("action", evt.Action),
104-
slog.String("error", err.Error()))
105-
http.Error(w, "event processing failed", http.StatusInternalServerError)
106-
return
100+
req := app.Request{
101+
Type: app.RequestTypeHTTP,
102+
Method: r.Method,
103+
Path: r.URL.Path,
104+
Headers: headers,
105+
Body: body,
107106
}
108107

109-
response := map[string]string{
110-
"status": "success",
111-
"message": "okta sync completed",
108+
resp := appInst.HandleRequest(r.Context(), req)
109+
110+
for key, value := range resp.Headers {
111+
w.Header().Set(key, value)
112+
}
113+
if resp.ContentType != "" && w.Header().Get("Content-Type") == "" {
114+
w.Header().Set("Content-Type", resp.ContentType)
112115
}
113116

114-
w.Header().Set("Content-Type", "application/json")
115-
w.WriteHeader(http.StatusOK)
116-
if err := json.NewEncoder(w).Encode(response); err != nil {
117-
logger.Error("failed to encode response", slog.String("error", err.Error()))
117+
w.WriteHeader(resp.StatusCode)
118+
if len(resp.Body) > 0 {
119+
w.Write(resp.Body)
118120
}
119121
}

cmd/verify/README.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -90,15 +90,18 @@ Path matching supports wildcards (`*`) for dynamic segments like org names, repo
9090
## Architecture
9191

9292
```
93-
Event → App Logic → SDK Client → Mock Server (localhost:9001-9003)
94-
95-
Record Request
96-
97-
Return Mock Response
98-
99-
Validate Expected Calls Made
93+
Test Scenario → app.HandleRequest() → SDK Client → Mock Server (localhost:9001-9003)
94+
95+
Record Request
96+
97+
Return Mock Response
98+
99+
Validate Expected Calls Made
100100
```
101101

102+
Tests use the unified `app.HandleRequest()` interface, ensuring the same code
103+
paths are exercised as production deployments.
104+
102105
Base URLs configured via environment (all HTTPS with self-signed certs):
103106
- `APP_GITHUB_BASE_URL``https://localhost:9001/`
104107
- `APP_OKTA_BASE_URL``https://localhost:9002/`

0 commit comments

Comments
 (0)