Skip to content

Commit

Permalink
feat: add healthz endpoint and promote Fn ID and Fn Version to request
Browse files Browse the repository at this point in the history
  • Loading branch information
jsteenb2 committed Jul 17, 2024
1 parent 3c1f5a4 commit fb3dcb2
Show file tree
Hide file tree
Showing 5 changed files with 207 additions and 78 deletions.
35 changes: 29 additions & 6 deletions mux.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import (
"context"
"fmt"
"net/http"
"os"
"strconv"
)

type routeKey struct {
method string
route string
}
const (
healthzRoute = "/healthz"
healthzMethod = http.MethodGet
)

// Mux defines a handler that will dispatch to a matching route/method combination. Much
// like the std lib http.ServeMux, but with slightly more opinionated route setting. We
Expand All @@ -23,11 +25,25 @@ type Mux struct {

// NewMux creates a new Mux that is ready for assignment.
func NewMux() *Mux {
return &Mux{
m := &Mux{
routes: make(map[string]bool),
meth2Routes: make(map[string]map[string]bool),
handlers: make(map[routeKey]Handler),
}

m.Get(healthzRoute, HandlerFn(func(ctx context.Context, r Request) Response {
return Response{
Code: http.StatusOK,
Body: JSON(map[string]string{
"status": "ok",
"fn_id": r.FnID,
"fn_build_version": os.Getenv("CS_FN_BUILD_VERSION"),
"fn_version": strconv.Itoa(r.FnVersion),
}),
}
}))

return m
}

// Handle enacts the handler to process the request/response lifecycle. The mux fulfills the
Expand Down Expand Up @@ -78,8 +94,10 @@ func (m *Mux) registerRoute(method, route string, h Handler) {
panic("handler must not be nil")
}

isHealthZ := route == healthzRoute && method == healthzMethod

rk := routeKey{route: route, method: method}
if _, ok := m.handlers[rk]; ok {
if _, ok := m.handlers[rk]; ok && !isHealthZ {
panic(fmt.Sprintf("multiple handlers added for: %q ", method+" "+route))
}

Expand Down Expand Up @@ -108,3 +126,8 @@ func (m *Mux) registerRoute(method, route string, h Handler) {

m.handlers[rk] = h
}

type routeKey struct {
method string
route string
}
8 changes: 6 additions & 2 deletions runner_http.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ func (r *runnerHTTP) Run(ctx context.Context, logger *slog.Logger, h Handler) {

func toRequest(req *http.Request) (Request, error) {
var r struct {
FnID string `json:"fn_id"`
FnVersion int `json:"fn_version"`
Body json.RawMessage `json:"body"`
Context json.RawMessage `json:"context"`
AccessToken string `json:"access_token"`
Expand Down Expand Up @@ -117,8 +119,10 @@ func toRequest(req *http.Request) (Request, error) {
r.Params.Header = hCanon

out := Request{
Body: r.Body,
Context: r.Context,
FnID: r.FnID,
FnVersion: r.FnVersion,
Body: r.Body,
Context: r.Context,
Params: struct {
Header http.Header
Query url.Values
Expand Down
178 changes: 167 additions & 11 deletions runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ func TestRun_httprunner(t *testing.T) {
AccessToken string `json:"access_token"`
Body json.RawMessage `json:"body"`
Context json.RawMessage `json:"context"`
FnID string `json:"fn_id"`
FnVersion int `json:"fn_version"`
Method string `json:"method"`
Params struct {
Header http.Header `json:"header"`
Expand Down Expand Up @@ -517,15 +519,7 @@ integer: 1`,
for _, tt := range tests {
fn := func(t *testing.T) {
if tt.inputs.config != "" {
cfgFile := tt.inputs.configFile
if cfgFile == "" {
cfgFile = "config.json"
}
tmp := filepath.Join(t.TempDir(), cfgFile)
t.Setenv("CS_FN_CONFIG_PATH", tmp)

err := os.WriteFile(tmp, []byte(tt.inputs.config), 0666)
mustNoErr(t, err)
writeConfigFile(t, tt.inputs.config, tt.inputs.configFile)
}

ctx, cancel := context.WithCancel(context.Background())
Expand Down Expand Up @@ -571,6 +565,150 @@ integer: 1`,
}
})

t.Run("when calling healthz handlers", func(t *testing.T) {
type (
inputs struct {
fnID string
fnBuildVersion int
fnVersion int
config string
}

respGeneric struct {
Code int `json:"code"`
Errs []fdk.APIError `json:"errors"`
Body json.RawMessage `json:"body"`
}

wantFn func(t *testing.T, resp *http.Response, got respGeneric)
)

tests := []struct {
name string
inputs inputs
newHandlerFn func(ctx context.Context, cfg config) fdk.Handler
want wantFn
}{
{
name: "hitting default healthz endpoint should return expected data",
inputs: inputs{
fnID: "id1",
fnBuildVersion: 1,
fnVersion: 2,
config: `{"string": "val","integer": 1}`,
},
newHandlerFn: func(ctx context.Context, cfg config) fdk.Handler {
return fdk.NewMux()
},
want: func(t *testing.T, resp *http.Response, got respGeneric) {
fdk.EqualVals(t, 200, resp.StatusCode)
fdk.EqualVals(t, 200, got.Code)

if len(got.Errs) > 0 {
t.Errorf("received unexpected errors\n\t\tgot:\t%+v", got.Errs)
}

var gotBody map[string]string
decodeJSON(t, got.Body, &gotBody)

fdk.EqualVals(t, "ok", gotBody["status"])
fdk.EqualVals(t, "id1", gotBody["fn_id"])
fdk.EqualVals(t, "1", gotBody["fn_build_version"])
fdk.EqualVals(t, "2", gotBody["fn_version"])
},
},
{
name: "when providing healthz endpoint should use provided healthz endpoint",
inputs: inputs{
config: `{"string": "val","integer": 1}`,
},
newHandlerFn: func(ctx context.Context, cfg config) fdk.Handler {
m := fdk.NewMux()
m.Get("/healthz", fdk.HandlerFn(func(ctx context.Context, r fdk.Request) fdk.Response {
return fdk.Response{
Code: http.StatusAccepted,
Body: fdk.JSON("ok"),
}
}))
return m
},
want: func(t *testing.T, resp *http.Response, got respGeneric) {
fdk.EqualVals(t, 202, resp.StatusCode)
fdk.EqualVals(t, 202, got.Code)

if len(got.Errs) > 0 {
t.Errorf("received unexpected errors\n\t\tgot:\t%+v", got.Errs)
}

var gotBody string
decodeJSON(t, got.Body, &gotBody)

fdk.EqualVals(t, "ok", gotBody)
},
},
{
name: "when config is invalid healthz endpoint should return errors",
inputs: inputs{
config: `{"string": "","integer": 0}`,
},
newHandlerFn: func(ctx context.Context, cfg config) fdk.Handler {
return fdk.NewMux()
},
want: func(t *testing.T, resp *http.Response, got respGeneric) {
fdk.EqualVals(t, 400, resp.StatusCode)
fdk.EqualVals(t, 400, got.Code)

wantErrs := []fdk.APIError{
{Code: 400, Message: "config is invalid: invalid config \"string\" field received: \ninvalid config \"integer\" field received: 0"},
}
fdk.EqualVals(t, len(wantErrs), len(got.Errs))
for i, want := range wantErrs {
fdk.EqualVals(t, want, got.Errs[i])
}
},
},
}

for _, tt := range tests {
fn := func(t *testing.T) {
if tt.inputs.config != "" {
writeConfigFile(t, tt.inputs.config, "")
}

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

addr := newServer(ctx, t, func(ctx context.Context, cfg config) fdk.Handler {
return tt.newHandlerFn(ctx, cfg)
})

t.Setenv("CS_FN_BUILD_VERSION", strconv.Itoa(tt.inputs.fnBuildVersion))

b, err := json.Marshal(testReq{
FnID: tt.inputs.fnID,
FnVersion: tt.inputs.fnVersion,
URL: "/healthz",
Method: http.MethodGet,
})
mustNoErr(t, err)

req, err := http.NewRequestWithContext(ctx, http.MethodPost, addr, bytes.NewBuffer(b))
mustNoErr(t, err)

resp, err := http.DefaultClient.Do(req)
mustNoErr(t, err)
cancel()
defer func() { _ = resp.Body.Close() }()

var got respGeneric
decodeBody(t, resp.Body, &got)

tt.want(t, resp, got)
}
t.Run(tt.name, fn)
}
})

t.Run("when executing with workflow integration", func(t *testing.T) {
type (
inputs struct {
Expand Down Expand Up @@ -1043,8 +1181,13 @@ func decodeBody(t testing.TB, r io.Reader, v any) {
t.Fatal("failed to read: " + err.Error())
}

err = json.Unmarshal(b, v)
if err != nil {
decodeJSON(t, b, v)
}

func decodeJSON(t testing.TB, b []byte, v any) {
t.Helper()

if err := json.Unmarshal(b, v); err != nil {
t.Fatalf("failed to unmarshal json: %s\n\t\tpayload:\t%s", err, string(b))
}
}
Expand Down Expand Up @@ -1090,3 +1233,16 @@ func newIP(t *testing.T) string {
}
return parts[len(parts)-1]
}

func writeConfigFile(t *testing.T, config, cfgFile string) {
t.Helper()

if cfgFile == "" {
cfgFile = "config.json"
}
tmp := filepath.Join(t.TempDir(), cfgFile)
t.Setenv("CS_FN_CONFIG_PATH", tmp)

err := os.WriteFile(tmp, []byte(config), 0666)
mustNoErr(t, err)
}
19 changes: 5 additions & 14 deletions sdk.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,6 @@ import (
"runtime/debug"
)

// Fn returns the active function id and version.
func Fn() struct {
ID string
} {
return struct {
ID string
}{
ID: os.Getenv("CS_FN_ID"),
}
}

// Handler provides a handler for our incoming request.
type Handler interface {
Handle(ctx context.Context, r Request) Response
Expand Down Expand Up @@ -110,9 +99,11 @@ type (

// RequestOf provides a generic body we can target our unmarshaling into.
RequestOf[T any] struct {
Body T
Context json.RawMessage
Params struct {
FnID string
FnVersion int
Body T
Context json.RawMessage
Params struct {
Header http.Header
Query url.Values
}
Expand Down
45 changes: 0 additions & 45 deletions sdk_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,51 +8,6 @@ import (
fdk "github.com/CrowdStrike/foundry-fn-go"
)

func TestFn(t *testing.T) {
type (
inputs struct {
fnID string
}

wantFn func(t *testing.T, gotFnID string)
)

tests := []struct {
name string
inputs inputs
wants wantFn
}{
{
name: "fn-id set with version 1",
inputs: inputs{
fnID: "fn-id",
},
wants: func(t *testing.T, gotFnID string) {
fdk.EqualVals(t, "fn-id", gotFnID)
},
},
{
name: "fn-id set without version",
inputs: inputs{
fnID: "fn-id",
},
wants: func(t *testing.T, gotFnID string) {
fdk.EqualVals(t, "fn-id", gotFnID)
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Setenv("CS_FN_ID", tt.inputs.fnID)

fn := fdk.Fn()
tt.wants(t, fn.ID)
})
}

}

func TestAPIError(t *testing.T) {
errs := []fdk.APIError{
{Code: http.StatusInternalServerError, Message: "some internal error"},
Expand Down

0 comments on commit fb3dcb2

Please sign in to comment.