diff --git a/api/models/fn.go b/api/models/fn.go index e9bfa2535..b6f76500d 100644 --- a/api/models/fn.go +++ b/api/models/fn.go @@ -72,6 +72,9 @@ var ( } ) +// FnInvokeEndpointAnnotation is the annotation that exposes the fn invoke endpoint For want of a better place to put this it's here +const FnInvokeEndpointAnnotation = "fnproject.io/fn/invokeEndpoint" + // Fn contains information about a function configuration. type Fn struct { // ID is the generated resource id. diff --git a/api/server/fn_annotator.go b/api/server/fn_annotator.go new file mode 100644 index 000000000..479e3278f --- /dev/null +++ b/api/server/fn_annotator.go @@ -0,0 +1,63 @@ +package server + +import ( + "fmt" + "strings" + + "github.com/fnproject/fn/api/models" + "github.com/gin-gonic/gin" +) + +//FnAnnotator Is used to inject trigger context (such as request URLs) into outbound trigger resources +type FnAnnotator interface { + // Annotates a trigger on read + AnnotateFn(ctx *gin.Context, a *models.App, fn *models.Fn) (*models.Fn, error) +} + +type requestBasedFnAnnotator struct{} + +func annotateFnWithBaseURL(baseURL string, app *models.App, fn *models.Fn) (*models.Fn, error) { + + baseURL = strings.TrimSuffix(baseURL, "/") + src := strings.TrimPrefix(fn.ID, "/") + triggerPath := fmt.Sprintf("%s/invoke/%s", baseURL, src) + + newT := fn.Clone() + newAnnotations, err := newT.Annotations.With(models.FnInvokeEndpointAnnotation, triggerPath) + if err != nil { + return nil, err + } + newT.Annotations = newAnnotations + return newT, nil +} + +func (tp *requestBasedFnAnnotator) AnnotateFn(ctx *gin.Context, app *models.App, t *models.Fn) (*models.Fn, error) { + + //No, I don't feel good about myself either + scheme := "http" + if ctx.Request.TLS != nil { + scheme = "https" + } + + return annotateFnWithBaseURL(fmt.Sprintf("%s://%s", scheme, ctx.Request.Host), app, t) +} + +//NewRequestBasedFnAnnotator creates a FnAnnotator that inspects the incoming request host and port, and uses this to generate fn invoke endpoint URLs based on those +func NewRequestBasedFnAnnotator() FnAnnotator { + return &requestBasedFnAnnotator{} +} + +type staticURLFnAnnotator struct { + baseURL string +} + +//NewStaticURLFnAnnotator annotates triggers bases on a given, specified URL base - e.g. "https://my.domain" ---> "https://my.domain/t/app/source" +func NewStaticURLFnAnnotator(baseURL string) FnAnnotator { + + return &staticURLFnAnnotator{baseURL: baseURL} +} + +func (s *staticURLFnAnnotator) AnnotateFn(ctx *gin.Context, app *models.App, trigger *models.Fn) (*models.Fn, error) { + return annotateFnWithBaseURL(s.baseURL, app, trigger) + +} diff --git a/api/server/fn_annotator_test.go b/api/server/fn_annotator_test.go new file mode 100644 index 000000000..9ee01fb62 --- /dev/null +++ b/api/server/fn_annotator_test.go @@ -0,0 +1,133 @@ +package server + +import ( + bytes2 "bytes" + "crypto/tls" + "encoding/json" + "net/http/httptest" + "testing" + + "github.com/fnproject/fn/api/models" + "github.com/gin-gonic/gin" +) + +func TestAnnotateFnDefaultProvider(t *testing.T) { + + app := &models.App{ + ID: "app_id", + Name: "myApp", + } + + tr := &models.Fn{ + ID: "fnID", + Name: "myFn", + AppID: app.ID, + } + + // defaults the fn endpoint to the base URL if it's not already set + tep := NewRequestBasedFnAnnotator() + + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest("GET", "/v2/foo/bar", bytes2.NewBuffer([]byte{})) + c.Request.Host = "my-server.com:8192" + newT, err := tep.AnnotateFn(c, app, tr) + + if err != nil { + t.Fatalf("expected no error, got %s", err) + } + + bytes, got := newT.Annotations.Get(models.FnInvokeEndpointAnnotation) + if !got { + t.Fatalf("Expecting annotation to be present but got %v", newT.Annotations) + } + + var annot string + err = json.Unmarshal(bytes, &annot) + if err != nil { + t.Fatalf("Couldn't get annotation") + } + + expected := "http://my-server.com:8192/invoke/fnID" + if annot != expected { + t.Errorf("expected annotation to be %s but was %s", expected, annot) + } +} + +func TestHttpsFn(t *testing.T) { + + app := &models.App{ + ID: "app_id", + Name: "myApp", + } + + tr := &models.Fn{ + ID: "fnID", + Name: "myFn", + AppID: app.ID, + } + + // defaults the Fn endpoint to the base URL if it's not already set + tep := NewRequestBasedFnAnnotator() + + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest("GET", "/v2/foo/bar", bytes2.NewBuffer([]byte{})) + c.Request.Host = "my-server.com:8192" + c.Request.TLS = &tls.ConnectionState{} + + newT, err := tep.AnnotateFn(c, app, tr) + + if err != nil { + t.Fatalf("expected no error, got %s", err) + } + + bytes, got := newT.Annotations.Get(models.FnInvokeEndpointAnnotation) + if !got { + t.Fatalf("Expecting annotation to be present but got %v", newT.Annotations) + } + var annot string + err = json.Unmarshal(bytes, &annot) + if err != nil { + t.Fatalf("Couldn't get annotation") + } + + expected := "https://my-server.com:8192/invoke/fnID" + if annot != expected { + t.Errorf("expected annotation to be %s but was %s", expected, annot) + } +} + +func TestStaticUrlFnAnnotator(t *testing.T) { + a := NewStaticURLFnAnnotator("http://foo.bar.com/somewhere") + + app := &models.App{ + ID: "app_id", + Name: "myApp", + } + + tr := &models.Fn{ + ID: "fnID", + Name: "myFn", + AppID: app.ID, + } + + newT, err := a.AnnotateFn(nil, app, tr) + if err != nil { + t.Fatalf("failed when should have succeeded: %s", err) + } + + bytes, got := newT.Annotations.Get(models.FnInvokeEndpointAnnotation) + if !got { + t.Fatalf("Expecting annotation to be present but got %v", newT.Annotations) + } + var annot string + err = json.Unmarshal(bytes, &annot) + if err != nil { + t.Fatalf("Couldn't get annotation") + } + + expected := "http://foo.bar.com/somewhere/invoke/fnID" + if annot != expected { + t.Errorf("expected annotation to be %s but was %s", expected, annot) + } + +} diff --git a/api/server/fns_get.go b/api/server/fns_get.go index a05bc7ffd..51368c373 100644 --- a/api/server/fns_get.go +++ b/api/server/fns_get.go @@ -1,6 +1,7 @@ package server import ( + "fmt" "net/http" "github.com/fnproject/fn/api" @@ -9,11 +10,24 @@ import ( func (s *Server) handleFnGet(c *gin.Context) { ctx := c.Request.Context() + f, err := s.datastore.GetFnByID(ctx, c.Param(api.ParamFnID)) if err != nil { handleErrorResponse(c, err) return } + app, err := s.datastore.GetAppByID(ctx, f.AppID) + if err != nil { + handleErrorResponse(c, fmt.Errorf("unexpected error - fn app not available: %s", err)) + return + } + + f, err = s.fnAnnotator.AnnotateFn(c, app, f) + if err != nil { + handleErrorResponse(c, err) + return + } + c.JSON(http.StatusOK, f) } diff --git a/api/server/fns_list.go b/api/server/fns_list.go index 73930ea9b..c610d21af 100644 --- a/api/server/fns_list.go +++ b/api/server/fns_list.go @@ -1,6 +1,7 @@ package server import ( + "fmt" "net/http" "github.com/fnproject/fn/api/models" @@ -21,5 +22,30 @@ func (s *Server) handleFnList(c *gin.Context) { return } + // Annotate the outbound fns + + // this is fairly cludgy bit hard to do in datastore middleware confidently + appCache := make(map[string]*models.App) + + for idx, f := range fns.Items { + app, ok := appCache[f.AppID] + if !ok { + gotApp, err := s.Datastore().GetAppByID(ctx, f.AppID) + if err != nil { + handleErrorResponse(c, fmt.Errorf("failed to get app for fn %s", err)) + return + } + app = gotApp + appCache[app.ID] = gotApp + } + + newF, err := s.fnAnnotator.AnnotateFn(c, app, f) + if err != nil { + handleErrorResponse(c, err) + return + } + fns.Items[idx] = newF + } + c.JSON(http.StatusOK, fns) } diff --git a/api/server/fns_test.go b/api/server/fns_test.go index d22d29bf4..e1dd76e3e 100644 --- a/api/server/fns_test.go +++ b/api/server/fns_test.go @@ -358,3 +358,53 @@ func TestFnGet(t *testing.T) { } } } + +func TestFnInvokeEndpointAnnotations(t *testing.T) { + a := &models.App{ID: "app_id", Name: "myapp"} + fn := &models.Fn{ID: "fnid", AppID: a.ID, Name: "fnname"} + + commonDS := datastore.NewMockInit([]*models.App{a}, []*models.Fn{fn}) + + srv := testServer(commonDS, &mqs.Mock{}, logs.NewMock(), nil, ServerTypeAPI) + + _, rec := routerRequest(t, srv.Router, "GET", "/v2/fns/fnid", bytes.NewBuffer([]byte(""))) + + if rec.Code != http.StatusOK { + t.Fatalf("expected code %d != 200", rec.Code) + } + var fnGet models.Fn + err := json.NewDecoder(rec.Body).Decode(&fnGet) + if err != nil { + t.Fatalf("Invalid json from server %s", err) + } + + const fnEndpoint = "fnproject.io/fn/invokeEndpoint" + v, err := fnGet.Annotations.GetString(fnEndpoint) + if err != nil { + t.Fatalf("failed to get fn %s", err) + } + if v != "http://127.0.0.1:8080/invoke/fnid" { + t.Errorf("unexpected fn val %s", v) + } + + _, rec = routerRequest(t, srv.Router, "GET", fmt.Sprintf("/v2/fns?app_id=%s", a.ID), nil) + + if rec.Code != http.StatusOK { + t.Fatalf("expected code %d != 200", rec.Code) + } + + var resp models.FnList + err = json.NewDecoder(rec.Body).Decode(&resp) + if err != nil { + t.Fatalf("Invalid json from server %s : %s", err, string(rec.Body.Bytes())) + } + + if len(resp.Items) != 1 { + t.Fatalf("Unexpected fn list result, %v", resp) + } + + v, err = resp.Items[0].Annotations.GetString(fnEndpoint) + if v != "http://127.0.0.1:8080/invoke/fnid" { + t.Errorf("unexpected fn val %s", v) + } +} diff --git a/api/server/server.go b/api/server/server.go index c7fd6fdcb..b8f2cc455 100644 --- a/api/server/server.go +++ b/api/server/server.go @@ -205,6 +205,7 @@ type Server struct { apiMiddlewares []fnext.Middleware promExporter *prometheus.Exporter triggerAnnotator TriggerAnnotator + fnAnnotator FnAnnotator // Extensions can append to this list of contexts so that cancellations are properly handled. extraCtxs []context.Context } @@ -257,8 +258,10 @@ func NewFromEnv(ctx context.Context, opts ...Option) *Server { if publicLBURL != "" { logrus.Infof("using LB Base URL: '%s'", publicLBURL) opts = append(opts, WithTriggerAnnotator(NewStaticURLTriggerAnnotator(publicLBURL))) + opts = append(opts, WithFnAnnotator(NewStaticURLFnAnnotator(publicLBURL))) } else { opts = append(opts, WithTriggerAnnotator(NewRequestBasedTriggerAnnotator())) + opts = append(opts, WithFnAnnotator(NewRequestBasedFnAnnotator())) } // Agent handling depends on node type and several other options so it must be the last processed option. @@ -580,6 +583,14 @@ func WithTriggerAnnotator(provider TriggerAnnotator) Option { } } +//WithFnAnnotator adds a fnEndpoint provider to the server +func WithFnAnnotator(provider FnAnnotator) Option { + return func(ctx context.Context, s *Server) error { + s.fnAnnotator = provider + return nil + } +} + // WithAdminServer starts the admin server on the specified port. func WithAdminServer(port int) Option { return func(ctx context.Context, s *Server) error { diff --git a/api/server/server_test.go b/api/server/server_test.go index a1cf3bbea..14688051f 100644 --- a/api/server/server_test.go +++ b/api/server/server_test.go @@ -38,6 +38,7 @@ func testServer(ds models.Datastore, mq models.MessageQueue, logDB models.LogSto WithAgent(rnr), WithType(nodeType), WithTriggerAnnotator(NewRequestBasedTriggerAnnotator()), + WithFnAnnotator(NewRequestBasedFnAnnotator()), )...) } diff --git a/api/server/trigger_get.go b/api/server/trigger_get.go index 3680b82c0..fcf283368 100644 --- a/api/server/trigger_get.go +++ b/api/server/trigger_get.go @@ -2,9 +2,10 @@ package server import ( "fmt" + "net/http" + "github.com/fnproject/fn/api" "github.com/gin-gonic/gin" - "net/http" ) func (s *Server) handleTriggerGet(c *gin.Context) { @@ -20,6 +21,7 @@ func (s *Server) handleTriggerGet(c *gin.Context) { if err != nil { handleErrorResponse(c, fmt.Errorf("unexpected error - trigger app not available: %s", err)) + return } trigger, err = s.triggerAnnotator.AnnotateTrigger(c, app, trigger) diff --git a/api/server/trigger_list.go b/api/server/trigger_list.go index 24c30dacb..22eca2b8e 100644 --- a/api/server/trigger_list.go +++ b/api/server/trigger_list.go @@ -4,6 +4,7 @@ import ( "net/http" "fmt" + "github.com/fnproject/fn/api/models" "github.com/gin-gonic/gin" ) @@ -30,10 +31,8 @@ func (s *Server) handleTriggerList(c *gin.Context) { } // Annotate the outbound triggers - // this is fairly cludgy bit hard to do in datastore middleware confidently appCache := make(map[string]*models.App) - newTriggers := make([]*models.Trigger, len(triggers.Items)) for idx, t := range triggers.Items { app, ok := appCache[t.AppID] @@ -52,9 +51,8 @@ func (s *Server) handleTriggerList(c *gin.Context) { handleErrorResponse(c, err) return } - newTriggers[idx] = newT + triggers.Items[idx] = newT } - triggers.Items = newTriggers c.JSON(http.StatusOK, triggers) } diff --git a/docs/swagger_invoke.yml b/docs/swagger_invoke.yml new file mode 100644 index 000000000..8e3cf8535 --- /dev/null +++ b/docs/swagger_invoke.yml @@ -0,0 +1,41 @@ +swagger: '2.0' +info: + title: fn + description: The open source serverless platform. + version: "2.0.0" +# the domain of the service +host: "127.0.0.1:8080" +# array of all schemes that your API supports +schemes: + - https + - http +# will be prefixed to all paths +basePath: /v2 + +paths: + /invoke/{fnID}: + post: + operationId: "InvokeFn" + summary: "Directly invoke a function" + parameters: + -name body + in: body + description: "Function invocation data" + responses: + 200: + description: "Function successfully invoked." + default: + description: "An unexpected error occurred." + schema: + $ref: '#/definitions/Error' + +definitions: + Error: + type: object + properties: + message: + type: string + readOnly: true + fields: + type: string + readOnly: true \ No newline at end of file diff --git a/test/fn-system-tests/system_test.go b/test/fn-system-tests/system_test.go index 3d306052b..cf1c5b97c 100644 --- a/test/fn-system-tests/system_test.go +++ b/test/fn-system-tests/system_test.go @@ -186,6 +186,7 @@ func SetUpAPINode(ctx context.Context) (*server.Server, error) { opts = append(opts, server.WithLogURL("")) opts = append(opts, server.WithLogstoreFromDatastore()) opts = append(opts, server.WithTriggerAnnotator(server.NewStaticURLTriggerAnnotator("http://localhost:8081"))) + opts = append(opts, server.WithFnAnnotator(server.NewStaticURLFnAnnotator("http://localhost:8081"))) opts = append(opts, server.EnableShutdownEndpoint(ctx, func() {})) // TODO: do it properly return server.New(ctx, opts...), nil }