Skip to content

Commit

Permalink
Add a HTTP handler for core interceptors
Browse files Browse the repository at this point in the history
This commit packages the 4 core interceptors into a single HTTP server.
Each interceptor is available at a different path e.g. /cel for CEL etc.

This does not wire up the implementation of the server into the
EventListener which will be done in a future commit.

Part of tektoncd#271

Signed-off-by: Dibyo Mukherjee <dibyo@google.com>
  • Loading branch information
dibyom committed Dec 8, 2020
1 parent 62d9ff6 commit f5baf0f
Show file tree
Hide file tree
Showing 2 changed files with 279 additions and 0 deletions.
123 changes: 123 additions & 0 deletions pkg/interceptors/server/server.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package server

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"

"github.com/tektoncd/triggers/pkg/interceptors/bitbucket"
"github.com/tektoncd/triggers/pkg/interceptors/cel"
"github.com/tektoncd/triggers/pkg/interceptors/github"
"github.com/tektoncd/triggers/pkg/interceptors/gitlab"

"github.com/tektoncd/triggers/pkg/apis/triggers/v1alpha1"
"go.uber.org/zap"
"k8s.io/client-go/kubernetes"
)

type Server struct {
KubeClient kubernetes.Interface
Logger *zap.SugaredLogger
interceptors map[string]v1alpha1.InterceptorInterface
}

func NewWithCoreInterceptors(k kubernetes.Interface, l *zap.SugaredLogger) *Server {
i := map[string]v1alpha1.InterceptorInterface{
"bitbucket": bitbucket.NewInterceptor(k, l).(v1alpha1.InterceptorInterface),
"cel": cel.NewInterceptor(k, l).(v1alpha1.InterceptorInterface),
"github": github.NewInterceptor(k, l).(v1alpha1.InterceptorInterface),
"gitlab": gitlab.NewInterceptor(k, l).(v1alpha1.InterceptorInterface),
}
s := Server{
KubeClient: k,
Logger: l,
interceptors: i,
}
return &s
}

func (is *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
b, err := is.ExecuteInterceptor(r)
if err != nil {
switch e := err.(type) {
case Error:
is.Logger.Infof("HTTP %d - %s", e.Status(), e)
http.Error(w, e.Error(), e.Status())
default:
is.Logger.Errorf("Non Status Error: %s", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}
w.Header().Add("Content-Type", "application/json")
if _, err = w.Write(b); err != nil {
is.Logger.Errorf("failed to write response: %s", err)
}
}

// Error represents a handler error. It provides methods for a HTTP status
// code and embeds the built-in error interface.
type Error interface {
error
Status() int
}

// HTTPError represents an error with an associated HTTP status code.
type HTTPError struct {
Code int
Err error
}

// Allows HTTPError to satisfy the error interface.
func (se HTTPError) Error() string {
return se.Err.Error()
}

// Returns our HTTP status code.
func (se HTTPError) Status() int {
return se.Code
}

func badRequest(err error) HTTPError {
return HTTPError{Code: http.StatusBadRequest, Err: err}
}

func internal(err error) HTTPError {
return HTTPError{Code: http.StatusInternalServerError, Err: err}
}

// Refactor using a service error: https://blog.questionable.services/article/http-handler-error-handling-revisited/
func (is *Server) ExecuteInterceptor(r *http.Request) ([]byte, error) {
var ii v1alpha1.InterceptorInterface

// Find correct interceptor
ii, ok := is.interceptors[strings.TrimPrefix(strings.ToLower(r.URL.Path), "/")]
if !ok {
return nil, badRequest(fmt.Errorf("path did not match any interceptors"))
}

// Create a context
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()

var body bytes.Buffer
defer r.Body.Close()
if _, err := io.Copy(&body, r.Body); err != nil {
return nil, internal(fmt.Errorf("failed to read body: %w", err))
}
var ireq v1alpha1.InterceptorRequest
if err := json.Unmarshal(body.Bytes(), &ireq); err != nil {
return nil, badRequest(fmt.Errorf("failed to parse body as InterceptorRequest: %w", err))
}
// Decorate ctx with eventID
iresp := ii.Process(ctx, &ireq)
respBytes, err := json.Marshal(iresp)
if err != nil {
return nil, internal(err)
}
return respBytes, nil
}
156 changes: 156 additions & 0 deletions pkg/interceptors/server/server_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package server

import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"strings"
"testing"

"google.golang.org/grpc/codes"

"github.com/google/go-cmp/cmp"

"github.com/tektoncd/triggers/pkg/apis/triggers/v1alpha1"
"go.uber.org/zap/zaptest"
fakekubeclient "knative.dev/pkg/client/injection/kube/client/fake"
rtesting "knative.dev/pkg/reconciler/testing"
)

func TestServer_ServeHTTP(t *testing.T) {
// includes error cases when the error is from interceptor processing.

testTriggerContext := &v1alpha1.TriggerContext{
EventURL: "http://something",
EventID: "abcde",
TriggerID: "namespaces/default/triggers/test-trigger",
}
tests := []struct {
name string
path string
req *v1alpha1.InterceptorRequest
want *v1alpha1.InterceptorResponse
}{{
name: "valid request that should continue",
path: "/cel",
req: &v1alpha1.InterceptorRequest{
Body: json.RawMessage(`{}`),
Header: map[string][]string{
"X-Event-Type": {"push"},
},
InterceptorParams: map[string]interface{}{
"filter": "header.canonical(\"X-Event-Type\") == \"push\"",
},
Context: testTriggerContext,
},
want: &v1alpha1.InterceptorResponse{
Continue: true,
},
}, {
name: "valid request that should not continue",
path: "/cel",
req: &v1alpha1.InterceptorRequest{
Body: json.RawMessage(`{}`),
Header: map[string][]string{
"X-Event-Type": {"push"},
},
InterceptorParams: map[string]interface{}{
"filter": "header.canonical(\"X-Event-Type\") == \"pull\"",
},
Context: testTriggerContext,
},
want: &v1alpha1.InterceptorResponse{
Continue: false,
Status: v1alpha1.Status{
Code: codes.FailedPrecondition,
Message: `expression header.canonical("X-Event-Type") == "pull" did not return true`,
},
},
}}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
logger := zaptest.NewLogger(t)
ctx, _ := rtesting.SetupFakeContext(t)
kubeClient := fakekubeclient.Get(ctx)

server := NewWithCoreInterceptors(kubeClient, logger.Sugar())
body, err := json.Marshal(tc.req)
if err != nil {
t.Fatalf("Failed to marshal errors ")
}
req := httptest.NewRequest("POST", fmt.Sprintf("http://example.com%s", tc.path), bytes.NewBuffer(body))
w := httptest.NewRecorder()
server.ServeHTTP(w, req)
resp := w.Result()
if resp.StatusCode != http.StatusOK {
t.Fatalf("ServeHTTP() expected statusCode 200 but got: %d", resp.StatusCode)
}
if resp.Header.Get("Content-Type") != "application/json" {
t.Fatalf("ServeHTTP() expected Content-Type header to be application/json but got: %s", resp.Header.Get("Content-Type"))
}

respBody, _ := ioutil.ReadAll(resp.Body)
defer resp.Body.Close()
got := v1alpha1.InterceptorResponse{}
if err := json.Unmarshal(respBody, &got); err != nil {
t.Fatalf("ServeHTTP() failed to unmarshal response into struct: %v", err)
}
if diff := cmp.Diff(tc.want, &got); diff != "" {
t.Fatalf("ServeHTTP() response did not match expected. Diff (-want/+got): %s", diff)
}
})
}

}

// Tests unexpected error cases where interceptor processing does not happen.
func TestServer_ServeHTTP_Error(t *testing.T) {
tests := []struct {
name string
path string
req []byte
wantResponseCode int
wantResponseBody string
}{{
name: "bad path",
path: "/invalid",
req: json.RawMessage(`{}`),
wantResponseCode: 400,
wantResponseBody: "path did not match any interceptors",
}, {
name: "invalid body",
path: "/cel",
req: json.RawMessage(`{}`),
wantResponseCode: 400,
wantResponseBody: "failed to parse body as InterceptorRequest",
}}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
logger := zaptest.NewLogger(t)
ctx, _ := rtesting.SetupFakeContext(t)
kubeClient := fakekubeclient.Get(ctx)

server := NewWithCoreInterceptors(kubeClient, logger.Sugar())
body, err := json.Marshal(tc.req)
if err != nil {
t.Fatalf("Failed to marshal errors ")
}
req := httptest.NewRequest("POST", fmt.Sprintf("http://example.com%s", tc.path), bytes.NewBuffer(body))
w := httptest.NewRecorder()
server.ServeHTTP(w, req)
resp := w.Result()
if resp.StatusCode != tc.wantResponseCode {
t.Fatalf("ServeHTTP() expected statusCode %d but got: %d", tc.wantResponseCode, resp.StatusCode)
}

respBody, _ := ioutil.ReadAll(resp.Body)
defer resp.Body.Close()
if !strings.Contains(string(respBody), tc.wantResponseBody) {
t.Fatalf("ServeHTTP() expected response to contain : %s \n but got %s: ", tc.wantResponseBody, string(respBody))
}
})
}
}

0 comments on commit f5baf0f

Please sign in to comment.