diff --git a/.golangci.yml b/.golangci.yml index c8e31d28..b4417231 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -79,14 +79,15 @@ linters: - gocritic - gofumpt - importas + - misspell - nilnil - prealloc - reassign - revive - stylecheck + - tparallel - usestdlibvars - wrapcheck - - misspell linters-settings: staticcheck: diff --git a/CHANGELOG.md b/CHANGELOG.md index 425e72cd..d5a1440a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add optional `description` field to workflows +- Job event notifications via server-sent events (see #11) ### Fixed @@ -21,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Refactored `wfxctl workflow delete` command to accept workflows as arguments instead of positional parameters - Prefer cgroup CPU quota over host CPU count +- Empty or `null` arrays are omitted from JSON responses ### Removed diff --git a/api/job_events_test.go b/api/job_events_test.go new file mode 100644 index 00000000..7a2c1cca --- /dev/null +++ b/api/job_events_test.go @@ -0,0 +1,135 @@ +package api + +/* + * SPDX-FileCopyrightText: 2023 Siemens AG + * + * SPDX-License-Identifier: Apache-2.0 + * + * Author: Michael Adler + */ + +import ( + "context" + "encoding/json" + "io" + "net/http" + "os" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + + "github.com/siemens/wfx/generated/model" + "github.com/siemens/wfx/internal/handler/job" + "github.com/siemens/wfx/internal/handler/job/events" + "github.com/siemens/wfx/internal/handler/job/status" + "github.com/siemens/wfx/internal/handler/workflow" + "github.com/siemens/wfx/workflow/dau" + "github.com/steinfletcher/apitest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestJobEventsSubscribe(t *testing.T) { + log.Logger = zerolog.New(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.Stamp}) + + db := newInMemoryDB(t) + wf := dau.DirectWorkflow() + _, err := workflow.CreateWorkflow(context.Background(), db, wf) + require.NoError(t, err) + + north, south := createNorthAndSouth(t, db) + + handlers := []http.Handler{north, south} + for i, name := range allAPIs { + handler := handlers[i] + t.Run(name, func(t *testing.T) { + clientID := "TestJobEventsSubscribe" + + var jobID atomic.Pointer[string] + + var wg sync.WaitGroup + expectedTags := []string{"tag1", "tag2"} + ch, _ := events.AddSubscriber(context.Background(), events.FilterParams{ClientIDs: []string{clientID}}, expectedTags) + wg.Add(1) + go func() { + defer wg.Done() + + // wait for job created event + ev := <-ch + payload := ev.Args[0].(*events.JobEvent) + assert.Equal(t, events.ActionCreate, payload.Action) + assert.Equal(t, expectedTags, payload.Tags) + jobID.Store(&payload.Job.ID) + + // wait for event created by our status.Update below + <-ch + // now our GET request should have received the response as well, + // add some extra time to be safe + time.Sleep(100 * time.Millisecond) + events.ShutdownSubscribers() + }() + + _, err := job.CreateJob(context.Background(), db, &model.JobRequest{ClientID: clientID, Workflow: wf.Name}) + require.NoError(t, err) + + wg.Add(1) + go func() { + defer wg.Done() + // wait for subscriber which is created by our GET request below and our test goroutine above + for events.SubscriberCount() != 2 { + time.Sleep(20 * time.Millisecond) + } + // update job + _, err = status.Update(context.Background(), db, *jobID.Load(), &model.JobStatus{State: "INSTALLING"}, model.EligibleEnumCLIENT) + require.NoError(t, err) + }() + + // wait for job id + for jobID.Load() == nil { + time.Sleep(20 * time.Millisecond) + } + + result := apitest.New(). + Handler(handler). + Get("/api/wfx/v1/jobs/events").Query("ids", *jobID.Load()). + Expect(t). + Status(http.StatusOK). + Header("Content-Type", "text/event-stream"). + End() + + data, _ := io.ReadAll(result.Response.Body) + body := string(data) + require.NotEmpty(t, body) + + lines := strings.Split(body, "\n") + + t.Log("HTTP resonse body:") + for _, line := range lines { + t.Logf(">> %s", line) + } + + assert.Len(t, lines, 4) + + // check body starts with data: + assert.True(t, strings.HasPrefix(lines[0], "data: ")) + + // check content is a job and state is INSTALLING + var ev events.JobEvent + err = json.Unmarshal([]byte(strings.TrimPrefix(lines[0], "data: ")), &ev) + require.NoError(t, err) + assert.Equal(t, events.ActionUpdateStatus, ev.Action) + assert.Equal(t, "INSTALLING", ev.Job.Status.State) + assert.Equal(t, wf.Name, ev.Job.Workflow.Name) + assert.Equal(t, clientID, ev.Job.ClientID) + assert.Equal(t, "id: 1", lines[1]) + + wg.Wait() + events.ShutdownSubscribers() + }) + } +} diff --git a/api/job_status_test.go b/api/job_status_test.go index 52708b08..009e7c2b 100644 --- a/api/job_status_test.go +++ b/api/job_status_test.go @@ -80,7 +80,7 @@ func TestJobStatusUpdate(t *testing.T) { apitest.New(). Handler(south). Put(statusPath). - Body(`{"clientId": "klaus", "state":"DOWNLOAD"}`). + Body(`{"clientId": "foo", "state":"DOWNLOAD"}`). ContentType("application/json"). Expect(t). Status(http.StatusBadRequest). @@ -90,7 +90,7 @@ func TestJobStatusUpdate(t *testing.T) { apitest.New(). Handler(north). Put(statusPath). - Body(`{"clientId": "klaus", "state":"DOWNLOAD"}`). + Body(`{"clientId": "foo", "state":"DOWNLOAD"}`). ContentType("application/json"). Expect(t). Status(http.StatusOK). @@ -101,7 +101,7 @@ func TestJobStatusUpdate(t *testing.T) { apitest.New(). Handler(north). Put(statusPath). - Body(`{"clientId": "klaus", "state":"DOWNLOADING"}`). + Body(`{"clientId": "foo", "state":"DOWNLOADING"}`). ContentType("application/json"). Expect(t). Status(http.StatusBadRequest). @@ -111,7 +111,7 @@ func TestJobStatusUpdate(t *testing.T) { apitest.New(). Handler(south). Put(statusPath). - Body(`{"clientId":"klaus","state":"DOWNLOADING"}`). + Body(`{"clientId":"foo","state":"DOWNLOADING"}`). ContentType("application/json"). Expect(t). Status(http.StatusOK). diff --git a/api/northbound.go b/api/northbound.go index 0b76dfa5..6629b989 100644 --- a/api/northbound.go +++ b/api/northbound.go @@ -11,6 +11,7 @@ package api import ( "fmt" "net/http" + "strings" "github.com/Southclaws/fault/ftag" "github.com/go-openapi/loads" @@ -21,10 +22,12 @@ import ( "github.com/siemens/wfx/generated/northbound/restapi/operations/northbound" "github.com/siemens/wfx/internal/handler/job" "github.com/siemens/wfx/internal/handler/job/definition" + "github.com/siemens/wfx/internal/handler/job/events" "github.com/siemens/wfx/internal/handler/job/status" "github.com/siemens/wfx/internal/handler/job/tags" "github.com/siemens/wfx/internal/handler/workflow" "github.com/siemens/wfx/middleware/logging" + "github.com/siemens/wfx/middleware/responder/sse" "github.com/siemens/wfx/persistence" ) @@ -266,5 +269,36 @@ func NewNorthboundAPI(storage persistence.Storage) *operations.WorkflowExecutorA return northbound.NewDeleteJobsIDTagsOK().WithPayload(tags) }) + serverAPI.NorthboundGetJobsEventsHandler = northbound.GetJobsEventsHandlerFunc( + func(params northbound.GetJobsEventsParams) middleware.Responder { + ctx := params.HTTPRequest.Context() + filter := parseFilterParamsNorth(params) + var tags []string + if s := params.Tags; s != nil { + tags = strings.Split(*s, ",") + } + eventChan, err := events.AddSubscriber(ctx, filter, tags) + if err != nil { + return northbound.NewGetJobsEventsDefault(http.StatusInternalServerError) + } + return sse.Responder(ctx, eventChan) + }) + return serverAPI } + +func parseFilterParamsNorth(params northbound.GetJobsEventsParams) events.FilterParams { + // same code as parseFilterParamsSouth but params is from a different package; + // this isn't pretty (DRY) but we have a conceptually clear distinction + var filter events.FilterParams + if ids := params.JobIds; ids != nil { + filter.JobIDs = strings.Split(*ids, ",") + } + if ids := params.ClientIds; ids != nil { + filter.ClientIDs = strings.Split(*ids, ",") + } + if wfs := params.Workflows; wfs != nil { + filter.Workflows = strings.Split(*wfs, ",") + } + return filter +} diff --git a/api/northbound_test.go b/api/northbound_test.go index 4884af38..e8284127 100644 --- a/api/northbound_test.go +++ b/api/northbound_test.go @@ -10,7 +10,6 @@ package api import ( "bytes" - "context" "errors" "fmt" "net/http" @@ -25,83 +24,18 @@ import ( "github.com/siemens/wfx/persistence" "github.com/siemens/wfx/workflow/dau" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" ) -type faultyStorage struct { - notFound bool - alreadyExists bool -} - -func (st *faultyStorage) Initialize(context.Context, string) error { - return nil -} - -func (st *faultyStorage) Shutdown() {} - -func (st *faultyStorage) CheckHealth(context.Context) error { - return nil -} - -func (st *faultyStorage) CreateJob(context.Context, *model.Job) (*model.Job, error) { - return nil, errors.New("CreateJob failed") -} - -func (st *faultyStorage) GetJob(context.Context, string, persistence.FetchParams) (*model.Job, error) { - if st.notFound { - return nil, fault.Wrap(errors.New("job not found"), ftag.With(ftag.NotFound)) - } - return nil, errors.New("GetJob failed") -} - -func (st *faultyStorage) UpdateJob(context.Context, *model.Job, persistence.JobUpdate) (*model.Job, error) { - return nil, errors.New("UpdateJob failed") -} - -func (st *faultyStorage) DeleteJob(context.Context, string) error { - if st.notFound { - return fault.Wrap(errors.New("job not found"), ftag.With(ftag.NotFound)) - } - return errors.New("DeleteJob failed") -} - -func (st *faultyStorage) QueryJobs(context.Context, persistence.FilterParams, persistence.SortParams, persistence.PaginationParams) (*model.PaginatedJobList, error) { - return nil, errors.New("QueryJobs failed") -} - -func (st *faultyStorage) CreateWorkflow(context.Context, *model.Workflow) (*model.Workflow, error) { - if st.alreadyExists { - return nil, fault.Wrap(errors.New("already exists"), ftag.With(ftag.AlreadyExists)) - } - return nil, errors.New("CreateWorkflow failed") -} - -func (st *faultyStorage) GetWorkflow(context.Context, string) (*model.Workflow, error) { - if st.notFound { - return nil, fault.Wrap(errors.New("workflow not found"), ftag.With(ftag.NotFound)) - } - return nil, errors.New("GetWorkflow failed") -} - -func (st *faultyStorage) DeleteWorkflow(context.Context, string) error { - if st.notFound { - return fault.Wrap(fmt.Errorf("workflow not found"), ftag.With(ftag.NotFound)) - } - return errors.New("DeleteWorkflow failed") -} - -func (st *faultyStorage) QueryWorkflows(context.Context, persistence.PaginationParams) (*model.PaginatedWorkflowList, error) { - if st.notFound { - return &model.PaginatedWorkflowList{}, nil - } - return nil, errors.New("QueryWorkflows failed") -} - func TestNorthboundGetJobsIDStatusHandler_NotFound(t *testing.T) { - api := NewNorthboundAPI(&faultyStorage{notFound: true}) - params := northbound.NewGetJobsIDStatusParams() + jobID := "42" + params.ID = jobID params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT().GetJob(params.HTTPRequest.Context(), params.ID, persistence.FetchParams{}).Return(nil, fault.Wrap(fmt.Errorf("job with id %s does not exist", jobID), ftag.With(ftag.NotFound))) + api := NewNorthboundAPI(dbMock) resp := api.NorthboundGetJobsIDStatusHandler.Handle(params) recorder := httptest.NewRecorder() @@ -112,11 +46,14 @@ func TestNorthboundGetJobsIDStatusHandler_NotFound(t *testing.T) { } func TestNorthboundPutJobsIDStatusHandler_NotFound(t *testing.T) { - api := NewNorthboundAPI(&faultyStorage{notFound: true}) - params := northbound.NewPutJobsIDStatusParams() + jobID := "42" + params.ID = jobID params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT().GetJob(params.HTTPRequest.Context(), params.ID, persistence.FetchParams{}).Return(nil, fault.Wrap(fmt.Errorf("job with id %s does not exist", jobID), ftag.With(ftag.NotFound))) + api := NewNorthboundAPI(dbMock) resp := api.NorthboundPutJobsIDStatusHandler.Handle(params) recorder := httptest.NewRecorder() @@ -127,10 +64,14 @@ func TestNorthboundPutJobsIDStatusHandler_NotFound(t *testing.T) { } func TestNorthboundGetJobsHandler_InternalError(t *testing.T) { - api := NewNorthboundAPI(&faultyStorage{}) - params := northbound.NewGetJobsParams() params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT(). + QueryJobs(params.HTTPRequest.Context(), persistence.FilterParams{}, persistence.SortParams{}, persistence.PaginationParams{Limit: 10}). + Return(nil, errors.New("something went wrong")) + + api := NewNorthboundAPI(dbMock) resp := api.NorthboundGetJobsHandler.Handle(params) recorder := httptest.NewRecorder() @@ -141,10 +82,15 @@ func TestNorthboundGetJobsHandler_InternalError(t *testing.T) { } func TestNorthboundGetJobsIDHandler_NotFound(t *testing.T) { - api := NewNorthboundAPI(&faultyStorage{notFound: true}) - params := northbound.NewGetJobsIDParams() + + jobID := "42" + params.ID = jobID params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT().GetJob(params.HTTPRequest.Context(), params.ID, persistence.FetchParams{}).Return(nil, fault.Wrap(fmt.Errorf("job with id %s does not exist", jobID), ftag.With(ftag.NotFound))) + + api := NewNorthboundAPI(dbMock) resp := api.NorthboundGetJobsIDHandler.Handle(params) recorder := httptest.NewRecorder() @@ -155,12 +101,16 @@ func TestNorthboundGetJobsIDHandler_NotFound(t *testing.T) { } func TestNorthboundGetJobsIDHandler_InternalError(t *testing.T) { - api := NewNorthboundAPI(&faultyStorage{}) - params := northbound.NewGetJobsIDParams() + jobID := "42" + params.ID = jobID params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) history := true params.History = &history + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT().GetJob(params.HTTPRequest.Context(), params.ID, persistence.FetchParams{History: history}).Return(nil, errors.New("something went wrong")) + + api := NewNorthboundAPI(dbMock) resp := api.NorthboundGetJobsIDHandler.Handle(params) recorder := httptest.NewRecorder() @@ -171,10 +121,14 @@ func TestNorthboundGetJobsIDHandler_InternalError(t *testing.T) { } func TestNorthboundGetJobsIDStatusHandler_InternalError(t *testing.T) { - api := NewNorthboundAPI(&faultyStorage{}) - params := northbound.NewGetJobsIDStatusParams() + jobID := "42" + params.ID = jobID params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT().GetJob(params.HTTPRequest.Context(), params.ID, persistence.FetchParams{}).Return(nil, errors.New("something went wrong")) + + api := NewNorthboundAPI(dbMock) resp := api.NorthboundGetJobsIDStatusHandler.Handle(params) recorder := httptest.NewRecorder() @@ -185,10 +139,14 @@ func TestNorthboundGetJobsIDStatusHandler_InternalError(t *testing.T) { } func TestNorthboundPutJobsIDStatusHandler_InternalError(t *testing.T) { - api := NewNorthboundAPI(&faultyStorage{}) - params := northbound.NewPutJobsIDStatusParams() + jobID := "42" + params.ID = jobID params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT().GetJob(params.HTTPRequest.Context(), params.ID, persistence.FetchParams{}).Return(nil, errors.New("something went wrong")) + + api := NewNorthboundAPI(dbMock) resp := api.NorthboundPutJobsIDStatusHandler.Handle(params) recorder := httptest.NewRecorder() @@ -199,10 +157,14 @@ func TestNorthboundPutJobsIDStatusHandler_InternalError(t *testing.T) { } func TestNorthboundGetJobsIDDefinitionHandler_NotFound(t *testing.T) { - api := NewNorthboundAPI(&faultyStorage{notFound: true}) - params := northbound.NewGetJobsIDDefinitionParams() + jobID := "42" + params.ID = jobID params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT().GetJob(params.HTTPRequest.Context(), params.ID, persistence.FetchParams{}).Return(nil, fault.Wrap(fmt.Errorf("job with id %s does not exist", jobID), ftag.With(ftag.NotFound))) + + api := NewNorthboundAPI(dbMock) resp := api.NorthboundGetJobsIDDefinitionHandler.Handle(params) recorder := httptest.NewRecorder() @@ -213,10 +175,14 @@ func TestNorthboundGetJobsIDDefinitionHandler_NotFound(t *testing.T) { } func TestNorthboundGetJobsIDDefinitionHandler_InternalError(t *testing.T) { - api := NewNorthboundAPI(&faultyStorage{}) - params := northbound.NewGetJobsIDDefinitionParams() + jobID := "42" + params.ID = jobID params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT().GetJob(params.HTTPRequest.Context(), params.ID, persistence.FetchParams{}).Return(nil, errors.New("something went wrong")) + + api := NewNorthboundAPI(dbMock) resp := api.NorthboundGetJobsIDDefinitionHandler.Handle(params) recorder := httptest.NewRecorder() @@ -227,10 +193,14 @@ func TestNorthboundGetJobsIDDefinitionHandler_InternalError(t *testing.T) { } func TestNorthboundPutJobsIDDefinitionHandler_NotFound(t *testing.T) { - api := NewNorthboundAPI(&faultyStorage{notFound: true}) - params := northbound.NewPutJobsIDDefinitionParams() + jobID := "42" + params.ID = jobID params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT().GetJob(params.HTTPRequest.Context(), params.ID, persistence.FetchParams{}).Return(nil, fault.Wrap(fmt.Errorf("job with id %s does not exist", jobID), ftag.With(ftag.NotFound))) + + api := NewNorthboundAPI(dbMock) resp := api.NorthboundPutJobsIDDefinitionHandler.Handle(params) recorder := httptest.NewRecorder() @@ -241,10 +211,14 @@ func TestNorthboundPutJobsIDDefinitionHandler_NotFound(t *testing.T) { } func TestNorthboundPutJobsIDDefinitionHandler_InternalError(t *testing.T) { - api := NewNorthboundAPI(&faultyStorage{}) - params := northbound.NewPutJobsIDDefinitionParams() + jobID := "42" + params.ID = jobID params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT().GetJob(params.HTTPRequest.Context(), params.ID, persistence.FetchParams{}).Return(nil, errors.New("something went wrong")) + + api := NewNorthboundAPI(dbMock) resp := api.NorthboundPutJobsIDDefinitionHandler.Handle(params) recorder := httptest.NewRecorder() @@ -255,10 +229,14 @@ func TestNorthboundPutJobsIDDefinitionHandler_InternalError(t *testing.T) { } func TestNorthboundGetWorkflowsNameHandler_InternalError(t *testing.T) { - api := NewNorthboundAPI(&faultyStorage{}) - params := northbound.NewGetWorkflowsNameParams() + params.Name = "wfx.test.workflow" params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT().GetWorkflow(params.HTTPRequest.Context(), params.Name).Return(nil, errors.New("something went wrong")) + + api := NewNorthboundAPI(dbMock) + resp := api.NorthboundGetWorkflowsNameHandler.Handle(params) recorder := httptest.NewRecorder() @@ -269,10 +247,12 @@ func TestNorthboundGetWorkflowsNameHandler_InternalError(t *testing.T) { } func TestNorthboundGetWorkflowsHandler_InternalError(t *testing.T) { - api := NewNorthboundAPI(&faultyStorage{}) - params := northbound.NewGetWorkflowsParams() params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT().QueryWorkflows(params.HTTPRequest.Context(), persistence.PaginationParams{Limit: 10}).Return(nil, errors.New("something went wrong")) + + api := NewNorthboundAPI(dbMock) resp := api.NorthboundGetWorkflowsHandler.Handle(params) recorder := httptest.NewRecorder() @@ -283,9 +263,13 @@ func TestNorthboundGetWorkflowsHandler_InternalError(t *testing.T) { } func TestNorthboundGetWorkflowsHandler_Empty(t *testing.T) { - api := NewNorthboundAPI(&faultyStorage{notFound: true}) - params := northbound.NewGetWorkflowsParams() + params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT().QueryWorkflows(params.HTTPRequest.Context(), persistence.PaginationParams{Limit: 10}).Return(nil, nil) + + api := NewNorthboundAPI(dbMock) + params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) resp := api.NorthboundGetWorkflowsHandler.Handle(params) @@ -297,10 +281,14 @@ func TestNorthboundGetWorkflowsHandler_Empty(t *testing.T) { } func TestNorthboundDeleteWorkflowsNameHandle_NotFound(t *testing.T) { - api := NewNorthboundAPI(&faultyStorage{notFound: true}) - params := northbound.NewDeleteWorkflowsNameParams() + params.Name = "wfx.workflow.test" params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT().DeleteWorkflow(params.HTTPRequest.Context(), params.Name).Return(fault.Wrap(errors.New("workflow not found"), ftag.With(ftag.NotFound))) + + api := NewNorthboundAPI(dbMock) + resp := api.NorthboundDeleteWorkflowsNameHandler.Handle(params) recorder := httptest.NewRecorder() @@ -311,10 +299,13 @@ func TestNorthboundDeleteWorkflowsNameHandle_NotFound(t *testing.T) { } func TestNorthboundDeleteWorkflowsNameHandle_InternalError(t *testing.T) { - api := NewNorthboundAPI(&faultyStorage{}) - params := northbound.NewDeleteWorkflowsNameParams() + params.Name = "wfx.workflow.test" params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT().DeleteWorkflow(params.HTTPRequest.Context(), params.Name).Return(errors.New("something went wrong")) + + api := NewNorthboundAPI(dbMock) resp := api.NorthboundDeleteWorkflowsNameHandler.Handle(params) recorder := httptest.NewRecorder() @@ -325,11 +316,13 @@ func TestNorthboundDeleteWorkflowsNameHandle_InternalError(t *testing.T) { } func TestNorthboundPostWorkflowsHandler_InternalError(t *testing.T) { - api := NewNorthboundAPI(&faultyStorage{}) - params := northbound.NewPostWorkflowsParams() params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) params.Workflow = dau.DirectWorkflow() + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT().CreateWorkflow(params.HTTPRequest.Context(), params.Workflow).Return(nil, errors.New("something went wrong")) + + api := NewNorthboundAPI(dbMock) resp := api.NorthboundPostWorkflowsHandler.Handle(params) recorder := httptest.NewRecorder() @@ -340,11 +333,13 @@ func TestNorthboundPostWorkflowsHandler_InternalError(t *testing.T) { } func TestNorthboundPostWorkflowsHandler_AlreadyExists(t *testing.T) { - api := NewNorthboundAPI(&faultyStorage{alreadyExists: true}) - params := northbound.NewPostWorkflowsParams() - params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) params.Workflow = dau.DirectWorkflow() + params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT().CreateWorkflow(params.HTTPRequest.Context(), params.Workflow).Return(nil, fault.Wrap(errors.New("already exists"), ftag.With(ftag.AlreadyExists))) + + api := NewNorthboundAPI(dbMock) resp := api.NorthboundPostWorkflowsHandler.Handle(params) recorder := httptest.NewRecorder() @@ -355,11 +350,12 @@ func TestNorthboundPostWorkflowsHandler_AlreadyExists(t *testing.T) { } func TestNorthboundPostWorkflowsHandler_InvalidWorkflow(t *testing.T) { - api := NewNorthboundAPI(&faultyStorage{}) - params := northbound.NewPostWorkflowsParams() - params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) params.Workflow = &model.Workflow{Name: "foo"} + params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) + dbMock := persistence.NewMockStorage(t) + + api := NewNorthboundAPI(dbMock) resp := api.NorthboundPostWorkflowsHandler.Handle(params) recorder := httptest.NewRecorder() @@ -369,12 +365,15 @@ func TestNorthboundPostWorkflowsHandler_InvalidWorkflow(t *testing.T) { assert.Equal(t, http.StatusBadRequest, response.StatusCode) } -func TestNorthboundPostJobsHandler_NotFound(t *testing.T) { - api := NewNorthboundAPI(&faultyStorage{notFound: true}) - +func TestNorthboundPostJobsHandler_BadRequest(t *testing.T) { params := northbound.NewPostJobsParams() + wf := dau.DirectWorkflow() + params.Job = &model.JobRequest{Workflow: wf.Name} params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) - params.Job = &model.JobRequest{} + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT().GetWorkflow(params.HTTPRequest.Context(), params.Job.Workflow).Return(nil, fault.Wrap(errors.New("invalid"), ftag.With(ftag.NotFound))) + + api := NewNorthboundAPI(dbMock) resp := api.NorthboundPostJobsHandler.Handle(params) recorder := httptest.NewRecorder() @@ -385,11 +384,16 @@ func TestNorthboundPostJobsHandler_NotFound(t *testing.T) { } func TestNorthboundPostJobsHandler_InternalError(t *testing.T) { - api := NewNorthboundAPI(&faultyStorage{}) - + wf := dau.DirectWorkflow() params := northbound.NewPostJobsParams() + params.Job = &model.JobRequest{Workflow: wf.Name} params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) - params.Job = &model.JobRequest{} + + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT().GetWorkflow(params.HTTPRequest.Context(), params.Job.Workflow).Return(wf, nil) + dbMock.EXPECT().CreateJob(params.HTTPRequest.Context(), mock.Anything).Return(nil, errors.New("something went wrong")) + + api := NewNorthboundAPI(dbMock) resp := api.NorthboundPostJobsHandler.Handle(params) recorder := httptest.NewRecorder() @@ -400,10 +404,15 @@ func TestNorthboundPostJobsHandler_InternalError(t *testing.T) { } func TestNorthboundDeleteJobsIDHandler_NotFound(t *testing.T) { - api := NewNorthboundAPI(&faultyStorage{notFound: true}) - params := northbound.NewDeleteJobsIDParams() + params.ID = "42" params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) + + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT().GetJob(params.HTTPRequest.Context(), params.ID, persistence.FetchParams{}).Return(&model.Job{}, nil) + dbMock.EXPECT().DeleteJob(params.HTTPRequest.Context(), params.ID).Return(fault.Wrap(errors.New("not found"), ftag.With(ftag.NotFound))) + + api := NewNorthboundAPI(dbMock) resp := api.NorthboundDeleteJobsIDHandler.Handle(params) recorder := httptest.NewRecorder() @@ -414,10 +423,15 @@ func TestNorthboundDeleteJobsIDHandler_NotFound(t *testing.T) { } func TestNorthboundDeleteJobsIDHandler_InternalError(t *testing.T) { - api := NewNorthboundAPI(&faultyStorage{}) - params := northbound.NewDeleteJobsIDParams() + params.ID = "42" params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) + + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT().GetJob(params.HTTPRequest.Context(), params.ID, persistence.FetchParams{}).Return(&model.Job{}, nil) + dbMock.EXPECT().DeleteJob(params.HTTPRequest.Context(), params.ID).Return(fault.Wrap(errors.New("something went wrong"), ftag.With(ftag.Internal))) + + api := NewNorthboundAPI(dbMock) resp := api.NorthboundDeleteJobsIDHandler.Handle(params) recorder := httptest.NewRecorder() @@ -428,10 +442,13 @@ func TestNorthboundDeleteJobsIDHandler_InternalError(t *testing.T) { } func TestNorthboundGetJobsIDTagsHandler_NotFound(t *testing.T) { - api := NewNorthboundAPI(&faultyStorage{notFound: true}) - params := northbound.NewGetJobsIDTagsParams() params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) + + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT().GetJob(params.HTTPRequest.Context(), params.ID, persistence.FetchParams{}).Return(nil, fault.Wrap(errors.New("not found"), ftag.With(ftag.NotFound))) + + api := NewNorthboundAPI(dbMock) resp := api.NorthboundGetJobsIDTagsHandler.Handle(params) recorder := httptest.NewRecorder() @@ -442,10 +459,13 @@ func TestNorthboundGetJobsIDTagsHandler_NotFound(t *testing.T) { } func TestNorthboundGetJobsIDTagsHandler_InternalError(t *testing.T) { - api := NewNorthboundAPI(&faultyStorage{}) - params := northbound.NewGetJobsIDTagsParams() params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) + + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT().GetJob(params.HTTPRequest.Context(), params.ID, persistence.FetchParams{}).Return(nil, fault.Wrap(errors.New("something went wrong"), ftag.With(ftag.Internal))) + + api := NewNorthboundAPI(dbMock) resp := api.NorthboundGetJobsIDTagsHandler.Handle(params) recorder := httptest.NewRecorder() @@ -456,10 +476,14 @@ func TestNorthboundGetJobsIDTagsHandler_InternalError(t *testing.T) { } func TestNorthboundPostJobsIDTagsHandler_NotFound(t *testing.T) { - api := NewNorthboundAPI(&faultyStorage{notFound: true}) - params := northbound.NewPostJobsIDTagsParams() + params.ID = "42" params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) + + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT().GetJob(params.HTTPRequest.Context(), params.ID, persistence.FetchParams{}).Return(nil, fault.Wrap(errors.New("not found"), ftag.With(ftag.NotFound))) + + api := NewNorthboundAPI(dbMock) resp := api.NorthboundPostJobsIDTagsHandler.Handle(params) recorder := httptest.NewRecorder() @@ -470,10 +494,14 @@ func TestNorthboundPostJobsIDTagsHandler_NotFound(t *testing.T) { } func TestNorthboundPostJobsIDTagsHandler_InternalError(t *testing.T) { - api := NewNorthboundAPI(&faultyStorage{}) - params := northbound.NewPostJobsIDTagsParams() + params.ID = "42" params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) + + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT().GetJob(params.HTTPRequest.Context(), params.ID, persistence.FetchParams{}).Return(nil, fault.Wrap(errors.New("something went wrong"), ftag.With(ftag.Internal))) + + api := NewNorthboundAPI(dbMock) resp := api.NorthboundPostJobsIDTagsHandler.Handle(params) recorder := httptest.NewRecorder() @@ -484,10 +512,13 @@ func TestNorthboundPostJobsIDTagsHandler_InternalError(t *testing.T) { } func TestNorthboundDeleteJobsIDTagsHandler_NotFound(t *testing.T) { - api := NewNorthboundAPI(&faultyStorage{notFound: true}) - params := northbound.NewDeleteJobsIDTagsParams() params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) + + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT().GetJob(params.HTTPRequest.Context(), params.ID, persistence.FetchParams{}).Return(nil, fault.Wrap(errors.New("not found"), ftag.With(ftag.NotFound))) + + api := NewNorthboundAPI(dbMock) resp := api.NorthboundDeleteJobsIDTagsHandler.Handle(params) recorder := httptest.NewRecorder() @@ -498,10 +529,13 @@ func TestNorthboundDeleteJobsIDTagsHandler_NotFound(t *testing.T) { } func TestNorthboundDeleteJobsIDTagsHandler_InternalError(t *testing.T) { - api := NewNorthboundAPI(&faultyStorage{}) - params := northbound.NewDeleteJobsIDTagsParams() params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) + + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT().GetJob(params.HTTPRequest.Context(), params.ID, persistence.FetchParams{}).Return(nil, fault.Wrap(errors.New("not found"), ftag.With(ftag.Internal))) + + api := NewNorthboundAPI(dbMock) resp := api.NorthboundDeleteJobsIDTagsHandler.Handle(params) recorder := httptest.NewRecorder() @@ -510,3 +544,19 @@ func TestNorthboundDeleteJobsIDTagsHandler_InternalError(t *testing.T) { assert.Equal(t, http.StatusInternalServerError, response.StatusCode) } + +func TestParseFilterParamsNorth(t *testing.T) { + jobIDs := "abc,424-194-123" + clientIDs := "alpha,beta" + workflows := "wf1,wf2,wf3" + params := northbound.GetJobsEventsParams{ + HTTPRequest: &http.Request{}, + JobIds: &jobIDs, + ClientIds: &clientIDs, + Workflows: &workflows, + } + filter := parseFilterParamsNorth(params) + assert.Equal(t, []string{"abc", "424-194-123"}, filter.JobIDs) + assert.Equal(t, []string{"alpha", "beta"}, filter.ClientIDs) + assert.Equal(t, []string{"wf1", "wf2", "wf3"}, filter.Workflows) +} diff --git a/api/southbound.go b/api/southbound.go index 79fbe0c4..26d5fb12 100644 --- a/api/southbound.go +++ b/api/southbound.go @@ -10,6 +10,7 @@ package api import ( "net/http" + "strings" "github.com/Southclaws/fault/ftag" "github.com/go-openapi/loads" @@ -20,10 +21,12 @@ import ( "github.com/siemens/wfx/generated/southbound/restapi/operations/southbound" "github.com/siemens/wfx/internal/handler/job" "github.com/siemens/wfx/internal/handler/job/definition" + "github.com/siemens/wfx/internal/handler/job/events" "github.com/siemens/wfx/internal/handler/job/status" "github.com/siemens/wfx/internal/handler/job/tags" "github.com/siemens/wfx/internal/handler/workflow" "github.com/siemens/wfx/middleware/logging" + "github.com/siemens/wfx/middleware/responder/sse" "github.com/siemens/wfx/persistence" ) @@ -174,5 +177,36 @@ func NewSouthboundAPI(storage persistence.Storage) *operations.WorkflowExecutorA return southbound.NewGetJobsIDTagsOK().WithPayload(tags) }) + serverAPI.SouthboundGetJobsEventsHandler = southbound.GetJobsEventsHandlerFunc( + func(params southbound.GetJobsEventsParams) middleware.Responder { + ctx := params.HTTPRequest.Context() + filter := parseFilterParamsSouth(params) + var tags []string + if s := params.Tags; s != nil { + tags = strings.Split(*s, ",") + } + eventChan, err := events.AddSubscriber(ctx, filter, tags) + if err != nil { + return southbound.NewGetJobsEventsDefault(http.StatusInternalServerError) + } + return sse.Responder(ctx, eventChan) + }) + return serverAPI } + +func parseFilterParamsSouth(params southbound.GetJobsEventsParams) events.FilterParams { + // same code as parseFilterParamsNorth but params is from a different package; + // this isn't pretty (DRY) but we have a conceptually clear distinction + var filter events.FilterParams + if ids := params.JobIds; ids != nil { + filter.JobIDs = strings.Split(*ids, ",") + } + if ids := params.ClientIds; ids != nil { + filter.ClientIDs = strings.Split(*ids, ",") + } + if wfs := params.Workflows; wfs != nil { + filter.Workflows = strings.Split(*wfs, ",") + } + return filter +} diff --git a/api/southbound_test.go b/api/southbound_test.go index a1086c0a..d2b2e6a2 100644 --- a/api/southbound_test.go +++ b/api/southbound_test.go @@ -10,21 +10,29 @@ package api import ( "bytes" + "errors" + "fmt" "net/http" "net/http/httptest" "testing" + "github.com/Southclaws/fault" + "github.com/Southclaws/fault/ftag" "github.com/siemens/wfx/generated/southbound/restapi/operations/southbound" "github.com/siemens/wfx/internal/producer" + "github.com/siemens/wfx/persistence" "github.com/stretchr/testify/assert" ) func TestSouthboundGetJobsIDStatusHandler_NotFound(t *testing.T) { - api := NewSouthboundAPI(&faultyStorage{notFound: true}) - params := southbound.NewGetJobsIDStatusParams() + jobID := "42" + params.ID = jobID params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT().GetJob(params.HTTPRequest.Context(), params.ID, persistence.FetchParams{}).Return(nil, fault.Wrap(fmt.Errorf("job with id %s does not exist", jobID), ftag.With(ftag.NotFound))) + api := NewSouthboundAPI(dbMock) resp := api.SouthboundGetJobsIDStatusHandler.Handle(params) recorder := httptest.NewRecorder() @@ -35,11 +43,14 @@ func TestSouthboundGetJobsIDStatusHandler_NotFound(t *testing.T) { } func TestSouthboundPutJobsIDStatusHandler_NotFound(t *testing.T) { - api := NewSouthboundAPI(&faultyStorage{notFound: true}) - params := southbound.NewPutJobsIDStatusParams() + jobID := "42" + params.ID = jobID params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT().GetJob(params.HTTPRequest.Context(), params.ID, persistence.FetchParams{}).Return(nil, fault.Wrap(fmt.Errorf("job with id %s does not exist", jobID), ftag.With(ftag.NotFound))) + api := NewSouthboundAPI(dbMock) resp := api.SouthboundPutJobsIDStatusHandler.Handle(params) recorder := httptest.NewRecorder() @@ -50,10 +61,14 @@ func TestSouthboundPutJobsIDStatusHandler_NotFound(t *testing.T) { } func TestSouthboundGetJobsHandler_InternalError(t *testing.T) { - api := NewSouthboundAPI(&faultyStorage{}) - params := southbound.NewGetJobsParams() params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT(). + QueryJobs(params.HTTPRequest.Context(), persistence.FilterParams{}, persistence.SortParams{}, persistence.PaginationParams{Limit: 10}). + Return(nil, errors.New("something went wrong")) + + api := NewSouthboundAPI(dbMock) resp := api.SouthboundGetJobsHandler.Handle(params) recorder := httptest.NewRecorder() @@ -64,10 +79,15 @@ func TestSouthboundGetJobsHandler_InternalError(t *testing.T) { } func TestSouthboundGetJobsIDHandler_NotFound(t *testing.T) { - api := NewSouthboundAPI(&faultyStorage{notFound: true}) - params := southbound.NewGetJobsIDParams() + + jobID := "42" + params.ID = jobID params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT().GetJob(params.HTTPRequest.Context(), params.ID, persistence.FetchParams{}).Return(nil, fault.Wrap(fmt.Errorf("job with id %s does not exist", jobID), ftag.With(ftag.NotFound))) + + api := NewSouthboundAPI(dbMock) resp := api.SouthboundGetJobsIDHandler.Handle(params) recorder := httptest.NewRecorder() @@ -78,12 +98,16 @@ func TestSouthboundGetJobsIDHandler_NotFound(t *testing.T) { } func TestSouthboundGetJobsIDHandler_InternalError(t *testing.T) { - api := NewSouthboundAPI(&faultyStorage{}) - params := southbound.NewGetJobsIDParams() + jobID := "42" + params.ID = jobID params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) history := true params.History = &history + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT().GetJob(params.HTTPRequest.Context(), params.ID, persistence.FetchParams{History: history}).Return(nil, errors.New("something went wrong")) + + api := NewSouthboundAPI(dbMock) resp := api.SouthboundGetJobsIDHandler.Handle(params) recorder := httptest.NewRecorder() @@ -94,10 +118,14 @@ func TestSouthboundGetJobsIDHandler_InternalError(t *testing.T) { } func TestSouthboundGetJobsIDStatusHandler_InternalError(t *testing.T) { - api := NewSouthboundAPI(&faultyStorage{}) - params := southbound.NewGetJobsIDStatusParams() + jobID := "42" + params.ID = jobID params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT().GetJob(params.HTTPRequest.Context(), params.ID, persistence.FetchParams{}).Return(nil, errors.New("something went wrong")) + + api := NewSouthboundAPI(dbMock) resp := api.SouthboundGetJobsIDStatusHandler.Handle(params) recorder := httptest.NewRecorder() @@ -108,10 +136,14 @@ func TestSouthboundGetJobsIDStatusHandler_InternalError(t *testing.T) { } func TestSouthboundPutJobsIDStatusHandler_InternalError(t *testing.T) { - api := NewSouthboundAPI(&faultyStorage{}) - params := southbound.NewPutJobsIDStatusParams() + jobID := "42" + params.ID = jobID params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT().GetJob(params.HTTPRequest.Context(), params.ID, persistence.FetchParams{}).Return(nil, errors.New("something went wrong")) + + api := NewSouthboundAPI(dbMock) resp := api.SouthboundPutJobsIDStatusHandler.Handle(params) recorder := httptest.NewRecorder() @@ -122,10 +154,14 @@ func TestSouthboundPutJobsIDStatusHandler_InternalError(t *testing.T) { } func TestSouthboundGetJobsIDDefinitionHandler_NotFound(t *testing.T) { - api := NewSouthboundAPI(&faultyStorage{notFound: true}) - params := southbound.NewGetJobsIDDefinitionParams() + jobID := "42" + params.ID = jobID params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT().GetJob(params.HTTPRequest.Context(), params.ID, persistence.FetchParams{}).Return(nil, fault.Wrap(fmt.Errorf("job with id %s does not exist", jobID), ftag.With(ftag.NotFound))) + + api := NewSouthboundAPI(dbMock) resp := api.SouthboundGetJobsIDDefinitionHandler.Handle(params) recorder := httptest.NewRecorder() @@ -136,10 +172,14 @@ func TestSouthboundGetJobsIDDefinitionHandler_NotFound(t *testing.T) { } func TestSouthboundGetJobsIDDefinitionHandler_InternalError(t *testing.T) { - api := NewSouthboundAPI(&faultyStorage{}) - params := southbound.NewGetJobsIDDefinitionParams() + jobID := "42" + params.ID = jobID params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT().GetJob(params.HTTPRequest.Context(), params.ID, persistence.FetchParams{}).Return(nil, errors.New("something went wrong")) + + api := NewSouthboundAPI(dbMock) resp := api.SouthboundGetJobsIDDefinitionHandler.Handle(params) recorder := httptest.NewRecorder() @@ -150,10 +190,14 @@ func TestSouthboundGetJobsIDDefinitionHandler_InternalError(t *testing.T) { } func TestSouthboundPutJobsIDDefinitionHandler_NotFound(t *testing.T) { - api := NewSouthboundAPI(&faultyStorage{notFound: true}) - params := southbound.NewPutJobsIDDefinitionParams() + jobID := "42" + params.ID = jobID params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT().GetJob(params.HTTPRequest.Context(), params.ID, persistence.FetchParams{}).Return(nil, fault.Wrap(fmt.Errorf("job with id %s does not exist", jobID), ftag.With(ftag.NotFound))) + + api := NewSouthboundAPI(dbMock) resp := api.SouthboundPutJobsIDDefinitionHandler.Handle(params) recorder := httptest.NewRecorder() @@ -164,10 +208,14 @@ func TestSouthboundPutJobsIDDefinitionHandler_NotFound(t *testing.T) { } func TestSouthboundPutJobsIDDefinitionHandler_InternalError(t *testing.T) { - api := NewSouthboundAPI(&faultyStorage{}) - params := southbound.NewPutJobsIDDefinitionParams() + jobID := "42" + params.ID = jobID params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT().GetJob(params.HTTPRequest.Context(), params.ID, persistence.FetchParams{}).Return(nil, errors.New("something went wrong")) + + api := NewSouthboundAPI(dbMock) resp := api.SouthboundPutJobsIDDefinitionHandler.Handle(params) recorder := httptest.NewRecorder() @@ -178,10 +226,14 @@ func TestSouthboundPutJobsIDDefinitionHandler_InternalError(t *testing.T) { } func TestSouthboundGetWorkflowsNameHandler_InternalError(t *testing.T) { - api := NewSouthboundAPI(&faultyStorage{}) - params := southbound.NewGetWorkflowsNameParams() + params.Name = "wfx.test.workflow" params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT().GetWorkflow(params.HTTPRequest.Context(), params.Name).Return(nil, errors.New("something went wrong")) + + api := NewSouthboundAPI(dbMock) + resp := api.SouthboundGetWorkflowsNameHandler.Handle(params) recorder := httptest.NewRecorder() @@ -192,10 +244,12 @@ func TestSouthboundGetWorkflowsNameHandler_InternalError(t *testing.T) { } func TestSouthboundGetWorkflowsHandler_InternalError(t *testing.T) { - api := NewSouthboundAPI(&faultyStorage{}) - params := southbound.NewGetWorkflowsParams() params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT().QueryWorkflows(params.HTTPRequest.Context(), persistence.PaginationParams{Limit: 10}).Return(nil, errors.New("something went wrong")) + + api := NewSouthboundAPI(dbMock) resp := api.SouthboundGetWorkflowsHandler.Handle(params) recorder := httptest.NewRecorder() @@ -206,9 +260,13 @@ func TestSouthboundGetWorkflowsHandler_InternalError(t *testing.T) { } func TestSouthboundGetWorkflowsHandler_Empty(t *testing.T) { - api := NewSouthboundAPI(&faultyStorage{notFound: true}) - params := southbound.NewGetWorkflowsParams() + params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT().QueryWorkflows(params.HTTPRequest.Context(), persistence.PaginationParams{Limit: 10}).Return(nil, nil) + + api := NewSouthboundAPI(dbMock) + params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) resp := api.SouthboundGetWorkflowsHandler.Handle(params) @@ -220,10 +278,13 @@ func TestSouthboundGetWorkflowsHandler_Empty(t *testing.T) { } func TestSouthboundGetJobsIDTagsHandler_NotFound(t *testing.T) { - api := NewSouthboundAPI(&faultyStorage{notFound: true}) - params := southbound.NewGetJobsIDTagsParams() params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) + + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT().GetJob(params.HTTPRequest.Context(), params.ID, persistence.FetchParams{}).Return(nil, fault.Wrap(errors.New("not found"), ftag.With(ftag.NotFound))) + + api := NewSouthboundAPI(dbMock) resp := api.SouthboundGetJobsIDTagsHandler.Handle(params) recorder := httptest.NewRecorder() @@ -234,10 +295,13 @@ func TestSouthboundGetJobsIDTagsHandler_NotFound(t *testing.T) { } func TestSouthboundGetJobsIDTagsHandler_InternalError(t *testing.T) { - api := NewSouthboundAPI(&faultyStorage{}) - params := southbound.NewGetJobsIDTagsParams() params.HTTPRequest = httptest.NewRequest(http.MethodGet, "http://localhost", new(bytes.Buffer)) + + dbMock := persistence.NewMockStorage(t) + dbMock.EXPECT().GetJob(params.HTTPRequest.Context(), params.ID, persistence.FetchParams{}).Return(nil, fault.Wrap(errors.New("something went wrong"), ftag.With(ftag.Internal))) + + api := NewSouthboundAPI(dbMock) resp := api.SouthboundGetJobsIDTagsHandler.Handle(params) recorder := httptest.NewRecorder() @@ -246,3 +310,19 @@ func TestSouthboundGetJobsIDTagsHandler_InternalError(t *testing.T) { assert.Equal(t, http.StatusInternalServerError, response.StatusCode) } + +func TestParseFilterParamsSouth(t *testing.T) { + jobIDs := "abc,424-194-123" + clientIDs := "alpha,beta" + workflows := "wf1,wf2,wf3" + params := southbound.GetJobsEventsParams{ + HTTPRequest: &http.Request{}, + JobIds: &jobIDs, + ClientIds: &clientIDs, + Workflows: &workflows, + } + filter := parseFilterParamsSouth(params) + assert.Equal(t, []string{"abc", "424-194-123"}, filter.JobIDs) + assert.Equal(t, []string{"alpha", "beta"}, filter.ClientIDs) + assert.Equal(t, []string{"wf1", "wf2", "wf3"}, filter.Workflows) +} diff --git a/api/workflow_test.go b/api/workflow_test.go index 25179974..c1c19d59 100644 --- a/api/workflow_test.go +++ b/api/workflow_test.go @@ -211,8 +211,11 @@ func createNorthAndSouth(t *testing.T, db persistence.Storage) (http.Handler, ht } func persistJob(t *testing.T, db persistence.Storage) *model.Job { - wf, err := workflow.CreateWorkflow(context.Background(), db, dau.DirectWorkflow()) - require.NoError(t, err) + wf := dau.DirectWorkflow() + if found, _ := workflow.GetWorkflow(context.Background(), db, wf.Name); found == nil { + _, err := workflow.CreateWorkflow(context.Background(), db, wf) + require.NoError(t, err) + } jobReq := model.JobRequest{ ClientID: "foo", diff --git a/cmd/wfx/cmd/root/cmd.go b/cmd/wfx/cmd/root/cmd.go index cfa74d82..21d33c5b 100644 --- a/cmd/wfx/cmd/root/cmd.go +++ b/cmd/wfx/cmd/root/cmd.go @@ -31,6 +31,7 @@ import ( "github.com/rs/zerolog/log" "github.com/siemens/wfx/cmd/wfx/metadata" "github.com/siemens/wfx/internal/config" + "github.com/siemens/wfx/internal/handler/job/events" "github.com/siemens/wfx/persistence" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -222,7 +223,10 @@ Examples of tasks are installation of firmware or other types of commands issued } } - // Create a context with a timeout to allow outstanding requests to complete + // shut down (disconnect) subscribers otherwise we cannot stop the web server due to open connections + events.ShutdownSubscribers() + + // create a context with a timeout to allow outstanding requests to complete var timeout time.Duration k.Read(func(k *koanf.Koanf) { timeout = k.Duration(gracefulTimeoutFlag) diff --git a/cmd/wfxctl/cmd/job/events/events.go b/cmd/wfxctl/cmd/job/events/events.go new file mode 100644 index 00000000..bea9853a --- /dev/null +++ b/cmd/wfxctl/cmd/job/events/events.go @@ -0,0 +1,145 @@ +package events + +/* + * SPDX-FileCopyrightText: 2023 Siemens AG + * + * SPDX-License-Identifier: Apache-2.0 + * + * Author: Michael Adler + */ + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + + "github.com/Southclaws/fault" + "github.com/go-openapi/runtime" + "github.com/go-openapi/runtime/client" + "github.com/go-openapi/strfmt" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" + "github.com/tmaxmax/go-sse" + + "github.com/siemens/wfx/cmd/wfxctl/errutil" + "github.com/siemens/wfx/cmd/wfxctl/flags" + generatedClient "github.com/siemens/wfx/generated/client" + "github.com/siemens/wfx/generated/client/jobs" + "github.com/siemens/wfx/generated/model" +) + +const ( + jobIDFlag = "job-id" + clientIDFlag = "client-id" + workflowNameFlag = "workflow-name" + tagFlag = "tag" +) + +var validator = func(out io.Writer) sse.ResponseValidator { + return func(r *http.Response) error { + if r.StatusCode == http.StatusOK { + return nil + } + + if r.Body != nil { + defer r.Body.Close() + b, err := io.ReadAll(r.Body) + if err != nil { + return fault.Wrap(err) + } + + errResp := new(model.ErrorResponse) + if err := json.Unmarshal(b, errResp); err != nil { + return fault.Wrap(err) + } + if len(errResp.Errors) > 0 { + for _, msg := range errResp.Errors { + fmt.Fprintf(out, "ERROR: %s (code=%s, logref=%s)\n", msg.Message, msg.Code, msg.Logref) + } + } + } + return fmt.Errorf("received HTTP status code: %d", r.StatusCode) + } +} + +func init() { + f := Command.Flags() + f.StringSlice(jobIDFlag, nil, "job id filter") + f.StringSlice(clientIDFlag, nil, "client id filter") + f.StringSlice(workflowNameFlag, nil, "workflow name filter") + f.StringSlice(tagFlag, nil, "tag filter") +} + +type SSETransport struct { + baseCmd *flags.BaseCmd + out io.Writer +} + +// Submit implements the runtime.ClientTransport interface. +func (t SSETransport) Submit(op *runtime.ClientOperation) (interface{}, error) { + cfg := t.baseCmd.CreateTransportConfig() + rt := client.New(cfg.Host, generatedClient.DefaultBasePath, cfg.Schemes) + req := errutil.Must(rt.CreateHttpRequest(op)) + + httpClient := errutil.Must(t.baseCmd.CreateHTTPClient()) + httpClient.Timeout = 0 + + client := sse.Client{ + HTTPClient: httpClient, + DefaultReconnectionTime: sse.DefaultClient.DefaultReconnectionTime, + ResponseValidator: validator(t.out), + } + + conn := client.NewConnection(req) + unsubscribe := conn.SubscribeMessages(func(event sse.Event) { + _, _ = os.Stdout.WriteString(event.Data) + os.Stdout.Write([]byte("\n")) + }) + defer unsubscribe() + + err := conn.Connect() + if err != nil { + return nil, fault.Wrap(err) + } + + return jobs.NewGetJobsEventsOK(), nil +} + +var Command = &cobra.Command{ + Use: "events", + Short: "Subscribe to job events", + Example: ` +wfxctl job events --job-id=1 --job-id=2 --client-id=foo +`, + TraverseChildren: true, + Run: func(cmd *cobra.Command, args []string) { + params := jobs.NewGetJobsEventsParams() + + if jobIDs := flags.Koanf.Strings(jobIDFlag); len(jobIDs) > 0 { + s := strings.Join(jobIDs, ",") + params.WithJobIds(&s) + } + if clientIds := flags.Koanf.Strings(clientIDFlag); len(clientIds) > 0 { + s := strings.Join(clientIds, ",") + params.WithClientIds(&s) + } + if workflowNames := flags.Koanf.Strings(workflowNameFlag); len(workflowNames) > 0 { + s := strings.Join(workflowNames, ",") + params.WithWorkflows(&s) + } + if tags := flags.Koanf.Strings(tagFlag); len(tags) > 0 { + s := strings.Join(tags, ",") + params.WithTags(&s) + } + + baseCmd := flags.NewBaseCmd() + transport := SSETransport{baseCmd: &baseCmd, out: cmd.OutOrStderr()} + executor := generatedClient.New(transport, strfmt.Default) + if _, err := executor.Jobs.GetJobsEvents(params); err != nil { + log.Fatal().Msg("Failed to subscribe to job events") + } + }, +} diff --git a/cmd/wfxctl/cmd/job/events/events_test.go b/cmd/wfxctl/cmd/job/events/events_test.go new file mode 100644 index 00000000..6cefc587 --- /dev/null +++ b/cmd/wfxctl/cmd/job/events/events_test.go @@ -0,0 +1,86 @@ +package events + +/* + * SPDX-FileCopyrightText: 2023 Siemens AG + * + * SPDX-License-Identifier: Apache-2.0 + * + * Author: Michael Adler + */ + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "testing" + + "github.com/siemens/wfx/cmd/wfxctl/flags" + "github.com/stretchr/testify/assert" +) + +func TestSubscribeJobStatus(t *testing.T) { + const expectedPath = "/api/wfx/v1/jobs/events" + var actualPath string + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + actualPath = r.URL.Path + + w.Header().Add("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`data: "hello world" + +`)) + })) + defer ts.Close() + + u, _ := url.Parse(ts.URL) + _ = flags.Koanf.Set(flags.ClientHostFlag, u.Hostname()) + port, _ := strconv.Atoi(u.Port()) + _ = flags.Koanf.Set(flags.ClientPortFlag, port) + + _ = flags.Koanf.Set(jobIDFlag, "1") + + err := Command.Execute() + assert.NoError(t, err) + + assert.Equal(t, expectedPath, actualPath) +} + +func TestValidator_OK(t *testing.T) { + out := new(bytes.Buffer) + resp := http.Response{StatusCode: http.StatusOK} + err := validator(out)(&resp) + assert.Nil(t, err) +} + +func TestValidator_Error(t *testing.T) { + out := new(bytes.Buffer) + resp := http.Response{StatusCode: http.StatusInternalServerError} + err := validator(out)(&resp) + assert.NotNil(t, err) +} + +func TestValidator_BadRequest(t *testing.T) { + out := new(bytes.Buffer) + + rec := httptest.NewRecorder() + rec.WriteHeader(http.StatusBadRequest) + + resp := rec.Result() + err := validator(out)(resp) + assert.NotNil(t, err) +} + +func TestValidator_BadRequestInvalidJson(t *testing.T) { + out := new(bytes.Buffer) + + rec := httptest.NewRecorder() + rec.WriteHeader(http.StatusBadRequest) + _, _ = rec.WriteString("data: foo") + + resp := rec.Result() + err := validator(out)(resp) + assert.NotNil(t, err) +} diff --git a/cmd/wfxctl/cmd/job/events/main_test.go b/cmd/wfxctl/cmd/job/events/main_test.go new file mode 100644 index 00000000..e52bf7ae --- /dev/null +++ b/cmd/wfxctl/cmd/job/events/main_test.go @@ -0,0 +1,19 @@ +package events + +/* + * SPDX-FileCopyrightText: 2023 Siemens AG + * + * SPDX-License-Identifier: Apache-2.0 + * + * Author: Michael Adler + */ + +import ( + "testing" + + "go.uber.org/goleak" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} diff --git a/cmd/wfxctl/cmd/job/job.go b/cmd/wfxctl/cmd/job/job.go index 167a5921..7ce13d0a 100644 --- a/cmd/wfxctl/cmd/job/job.go +++ b/cmd/wfxctl/cmd/job/job.go @@ -13,6 +13,7 @@ import ( "github.com/siemens/wfx/cmd/wfxctl/cmd/job/create" "github.com/siemens/wfx/cmd/wfxctl/cmd/job/delete" "github.com/siemens/wfx/cmd/wfxctl/cmd/job/deltags" + "github.com/siemens/wfx/cmd/wfxctl/cmd/job/events" "github.com/siemens/wfx/cmd/wfxctl/cmd/job/get" "github.com/siemens/wfx/cmd/wfxctl/cmd/job/getdefinition" "github.com/siemens/wfx/cmd/wfxctl/cmd/job/getstatus" @@ -43,4 +44,5 @@ func init() { Command.AddCommand(addtags.Command) Command.AddCommand(deltags.Command) Command.AddCommand(gettags.Command) + Command.AddCommand(events.Command) } diff --git a/cmd/wfxctl/cmd/job/updatestatus/update_status_test.go b/cmd/wfxctl/cmd/job/updatestatus/update_status_test.go index 54a5f32c..a3cd3479 100644 --- a/cmd/wfxctl/cmd/job/updatestatus/update_status_test.go +++ b/cmd/wfxctl/cmd/job/updatestatus/update_status_test.go @@ -40,7 +40,7 @@ func TestUpdateJobStatus(t *testing.T) { port, _ := strconv.Atoi(u.Port()) _ = flags.Koanf.Set(flags.ClientPortFlag, port) - _ = flags.Koanf.Set(clientIDFlag, "klaus") + _ = flags.Koanf.Set(clientIDFlag, "foo") _ = flags.Koanf.Set(messageFlag, "this is a test") _ = flags.Koanf.Set(progressFlag, int32(42)) _ = flags.Koanf.Set(stateFlag, "DOWNLOADED") @@ -51,5 +51,5 @@ func TestUpdateJobStatus(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "/api/wfx/v1/jobs/1/status", actualPath) - assert.JSONEq(t, `{"clientId": "klaus", "message":"this is a test","progress":42,"state":"DOWNLOADED"}`, string(body)) + assert.JSONEq(t, `{"clientId": "foo", "message":"this is a test","progress":42,"state":"DOWNLOADED"}`, string(body)) } diff --git a/cmd/wfxctl/flags/basecmd.go b/cmd/wfxctl/flags/basecmd.go index cbe2a999..d47bcc28 100644 --- a/cmd/wfxctl/flags/basecmd.go +++ b/cmd/wfxctl/flags/basecmd.go @@ -87,6 +87,7 @@ func (b *BaseCmd) CreateHTTPClient() (*http.Client, error) { if err != nil { return nil, fault.Wrap(err) } + log.Info().Msg("Using unix-domain socket transport") return &http.Client{ Transport: &http.Transport{ Dial: func(_, _ string) (net.Conn, error) { @@ -125,6 +126,10 @@ func (b *BaseCmd) CreateHTTPClient() (*http.Client, error) { } func (b *BaseCmd) CreateClient() *client.WorkflowExecutor { + return client.NewHTTPClientWithConfig(strfmt.Default, b.CreateTransportConfig()) +} + +func (b *BaseCmd) CreateTransportConfig() *client.TransportConfig { var host string var schemes []string if b.EnableTLS { @@ -134,11 +139,9 @@ func (b *BaseCmd) CreateClient() *client.WorkflowExecutor { schemes = []string{"http"} host = fmt.Sprintf("%s:%d", b.Host, b.Port) } - - cfg := client.DefaultTransportConfig(). + return client.DefaultTransportConfig(). WithHost(host). WithSchemes(schemes) - return client.NewHTTPClientWithConfig(strfmt.Default, cfg) } func (b *BaseCmd) CreateMgmtClient() *client.WorkflowExecutor { diff --git a/docs/operations.md b/docs/operations.md index 2c8faf8f..27d6faa0 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -28,6 +28,140 @@ Clients may inspect this specification at run-time so to obey the various limits For convenience, wfx includes a built-in Swagger UI accessible at runtime via , assuming default listening host and port [configuration](configuration.md). +### Job Events + +Job events provide a notification mechanism that informs clients about certain operations happening on jobs. +This approach eliminates the need for clients to continuously poll wfx, thus optimizing network usage and client resources. +Job events can be useful for user interfaces (UIs) and other applications that demand near-instantaneous updates. + +#### Architecture + +Below is a high-level overview of how the communication flow operates: + +```txt + ┌────────┐ ┌─────┐ + │ Client │ │ wfx │ + └────────┘ └─────┘ + | | + | HTTP GET /jobs/events | + |-----------------------------------►| + | | + | | + | Event Loop | + ┌───|────────────────────────────────────|───┐ + │ | [Content-Type: text/event-stream] | │ + │ | | │ + │ | | │ + │ | Push Event | │ + │ |◄-----------------------------------| │ + │ | | │ + └───|────────────────────────────────────|───┘ + | | + ▼ ▼ + ┌────────┐ ┌─────┐ + │ Client │ │ wfx │ + └────────┘ └─────┘ +``` + +1. The client initiates communication by sending an HTTP `GET` request to the `/jobs/events` endpoint. Clients may also + include optional [filter parameters](#filter-parameters) within the request. +2. Upon receipt of the request, `wfx` sets the `Content-Type` header to `text/event-stream`. +3. The server then initiates a stream of job events in the response body, allowing clients to receive instant updates. + +#### Event Format Specification + +The job events stream is composed of [server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) (SSE). +Accordingly, the stream is structured as follows: + +``` +data: [...] +id: 1 + +data: [...] +id: 2 + +[...] +``` + +An individual event within the stream conforms to this format: + +``` +data: { "action": "", "ctime": , "tags": , "job": } +id: \n\n +``` + +**Note**: Each event is terminated by a pair of newline characters `\n\n` (as required by the SSE spec). + +The semantics of the individual fields is: + +- `` specifies the type of event that occurred. The valid actions are: + - `CREATE`: a new job has been created + - `DELETE`: an existing job has been deleted + - `ADD_TAGS`: tags were added to a job + - `DELETE_TAGS`: tags were removed from a job + - `UPDATE_STATUS`: job status has been updated + - `UPDATE_DEFINITION`: job definition has been updated +- ``: event creation time (ISO8601) +- ``: JSON array of tags as provided by the client +- `` is a JSON object containing the portion of the job object which was changed, e.g., for an `UPDATE_STATUS` event, the job status is sent but not its definition. To enable [filtering](#filter-parameters), the fields `id`, `clientId` and `workflow.name` are _always_ part of the response. +- ``: an integer which uniquely identifies each event, starting at 1 and incrementing by 1 for every subsequent event. Clients can use this to identify any missed messages. If an overflow occurs, the integer resets to zero, a scenario the client can recognize and address. + +**Example:** + +``` +data: {"action":"UPDATE_STATUS","job":{"clientId":"Dana","id":"c6698105-6386-4940-a311-de1b57e3faeb","status":{"definitionHash":"adc1cfc1577119ba2a0852133340088390c1103bdf82d8102970d3e6c53ec10b","state":"PROGRESS"},"workflow":{"name":"wfx.workflow.kanban"}}} +id: 1\n\n +``` + +#### Filter Parameters + +Job events can be filtered using any combination of the following parameters: + +- Job IDs +- Client IDs +- Workflow Names + +This enables more precise control over the dispatched events. Note that it is entirely possible to subscribe multiple +times to job events using various filters in order to create a more advanced event recognition model. + +#### Examples + +`wfxctl` offers a reference client implementation. The following command subscribes to **all** job events: + +```bash +wfxctl job events +``` + +This may result in a large number of events, though. For a more targeted approach, filter parameters may be used. +Assuming the job IDs are known (either because the jobs have been created already or the IDs are received via another +subscription channel), the following will subscribe to events matching either of the two specified job IDs: + +```bash +wfxctl job events --job-id=d305e539-1d41-4c95-b19a-2a7055c469d0 --job-id=e692ad92-45e6-4164-b3fd-8c6aa884011c +``` + +See `wfxctl job events --help` for other filter parameters, e.g. workflow names. + +#### Considerations and Limitations + +1. **Asynchronous Job Status Updates**: Job status updates are dispatched asynchronously to avoid the risk of a + subscriber interfering with the actual job operation. In particular, there is no guarantee that the messages sent to + the client arrive in a linear order (another reason for that may be networking-related). While this is typically not + a concern, it could become an issue in high-concurrency situations. For example, when multiple clients try to modify + the same job or when a single client issues a rapid sequence of status updates. As a result, messages could arrive in + a not necessarily linear order, possibly deviating from the client's expectation. However, the client can use the + (event) `id` and `ctime` fields to establish a natural ordering of events as emitted by wfx. +2. **Unacknowledged Server-Sent Events (SSE)**: SSE operates on a one-way communication model and does not include an + acknowledgment or handshake protocol to confirm message delivery. This design choice aligns with the fundamental + principles of SSE but does mean that there's a possibility some events may not reach the intended subscriber (which + the client can possibly detect by keeping track of SSE event IDs). +3. **Event Stream Orchestration**: Each wfx instance only yields the events happening on that instance. Consequently, if + there are multiple wfx instances, a consolidated "global" event stream can only be assembled by subscribing to all + wfx instances (and aggregating the events). +4. **Browser Connection Limits for SSE**: Web browsers typically restrict the number of SSE connections to six per + domain. To overcome this limitation, HTTP/2 can be used, allowing up to 100 connections by default, or [filter + parameters](#filter-parameters) can be utilized to efficiently manage the connections. + ### Response Filters wfx allows server-side response content filtering prior to sending the response to the client so to tailor it to client information needs. diff --git a/generated/client/jobs/get_jobs_events_parameters.go b/generated/client/jobs/get_jobs_events_parameters.go new file mode 100644 index 00000000..6909594d --- /dev/null +++ b/generated/client/jobs/get_jobs_events_parameters.go @@ -0,0 +1,271 @@ +// Code generated by go-swagger; DO NOT EDIT. + +// SPDX-FileCopyrightText: 2023 Siemens AG +// +// SPDX-License-Identifier: Apache-2.0 +// + +package jobs + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + "net/http" + "time" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + cr "github.com/go-openapi/runtime/client" + "github.com/go-openapi/strfmt" +) + +// NewGetJobsEventsParams creates a new GetJobsEventsParams object, +// with the default timeout for this client. +// +// Default values are not hydrated, since defaults are normally applied by the API server side. +// +// To enforce default values in parameter, use SetDefaults or WithDefaults. +func NewGetJobsEventsParams() *GetJobsEventsParams { + return &GetJobsEventsParams{ + timeout: cr.DefaultTimeout, + } +} + +// NewGetJobsEventsParamsWithTimeout creates a new GetJobsEventsParams object +// with the ability to set a timeout on a request. +func NewGetJobsEventsParamsWithTimeout(timeout time.Duration) *GetJobsEventsParams { + return &GetJobsEventsParams{ + timeout: timeout, + } +} + +// NewGetJobsEventsParamsWithContext creates a new GetJobsEventsParams object +// with the ability to set a context for a request. +func NewGetJobsEventsParamsWithContext(ctx context.Context) *GetJobsEventsParams { + return &GetJobsEventsParams{ + Context: ctx, + } +} + +// NewGetJobsEventsParamsWithHTTPClient creates a new GetJobsEventsParams object +// with the ability to set a custom HTTPClient for a request. +func NewGetJobsEventsParamsWithHTTPClient(client *http.Client) *GetJobsEventsParams { + return &GetJobsEventsParams{ + HTTPClient: client, + } +} + +/* +GetJobsEventsParams contains all the parameters to send to the API endpoint + + for the get jobs events operation. + + Typically these are written to a http.Request. +*/ +type GetJobsEventsParams struct { + + /* ClientIds. + + The job's clientId must be one of these clientIds (comma-separated). + */ + ClientIds *string + + /* JobIds. + + The job's id must be one of these ids (comma-separated). + */ + JobIds *string + + /* Tags. + + A (comma-separated) list of tags to include into each job event. This can be used to aggregrate events from multiple wfx instances. + + */ + Tags *string + + /* Workflows. + + The job's workflow must be equal to one of the provided workflow names (comma-separated). + */ + Workflows *string + + timeout time.Duration + Context context.Context + HTTPClient *http.Client +} + +// WithDefaults hydrates default values in the get jobs events params (not the query body). +// +// All values with no default are reset to their zero value. +func (o *GetJobsEventsParams) WithDefaults() *GetJobsEventsParams { + o.SetDefaults() + return o +} + +// SetDefaults hydrates default values in the get jobs events params (not the query body). +// +// All values with no default are reset to their zero value. +func (o *GetJobsEventsParams) SetDefaults() { + // no default values defined for this parameter +} + +// WithTimeout adds the timeout to the get jobs events params +func (o *GetJobsEventsParams) WithTimeout(timeout time.Duration) *GetJobsEventsParams { + o.SetTimeout(timeout) + return o +} + +// SetTimeout adds the timeout to the get jobs events params +func (o *GetJobsEventsParams) SetTimeout(timeout time.Duration) { + o.timeout = timeout +} + +// WithContext adds the context to the get jobs events params +func (o *GetJobsEventsParams) WithContext(ctx context.Context) *GetJobsEventsParams { + o.SetContext(ctx) + return o +} + +// SetContext adds the context to the get jobs events params +func (o *GetJobsEventsParams) SetContext(ctx context.Context) { + o.Context = ctx +} + +// WithHTTPClient adds the HTTPClient to the get jobs events params +func (o *GetJobsEventsParams) WithHTTPClient(client *http.Client) *GetJobsEventsParams { + o.SetHTTPClient(client) + return o +} + +// SetHTTPClient adds the HTTPClient to the get jobs events params +func (o *GetJobsEventsParams) SetHTTPClient(client *http.Client) { + o.HTTPClient = client +} + +// WithClientIds adds the clientIds to the get jobs events params +func (o *GetJobsEventsParams) WithClientIds(clientIds *string) *GetJobsEventsParams { + o.SetClientIds(clientIds) + return o +} + +// SetClientIds adds the clientIds to the get jobs events params +func (o *GetJobsEventsParams) SetClientIds(clientIds *string) { + o.ClientIds = clientIds +} + +// WithJobIds adds the jobIds to the get jobs events params +func (o *GetJobsEventsParams) WithJobIds(jobIds *string) *GetJobsEventsParams { + o.SetJobIds(jobIds) + return o +} + +// SetJobIds adds the jobIds to the get jobs events params +func (o *GetJobsEventsParams) SetJobIds(jobIds *string) { + o.JobIds = jobIds +} + +// WithTags adds the tags to the get jobs events params +func (o *GetJobsEventsParams) WithTags(tags *string) *GetJobsEventsParams { + o.SetTags(tags) + return o +} + +// SetTags adds the tags to the get jobs events params +func (o *GetJobsEventsParams) SetTags(tags *string) { + o.Tags = tags +} + +// WithWorkflows adds the workflows to the get jobs events params +func (o *GetJobsEventsParams) WithWorkflows(workflows *string) *GetJobsEventsParams { + o.SetWorkflows(workflows) + return o +} + +// SetWorkflows adds the workflows to the get jobs events params +func (o *GetJobsEventsParams) SetWorkflows(workflows *string) { + o.Workflows = workflows +} + +// WriteToRequest writes these params to a swagger request +func (o *GetJobsEventsParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error { + + if err := r.SetTimeout(o.timeout); err != nil { + return err + } + var res []error + + if o.ClientIds != nil { + + // query param clientIds + var qrClientIds string + + if o.ClientIds != nil { + qrClientIds = *o.ClientIds + } + qClientIds := qrClientIds + if qClientIds != "" { + + if err := r.SetQueryParam("clientIds", qClientIds); err != nil { + return err + } + } + } + + if o.JobIds != nil { + + // query param jobIds + var qrJobIds string + + if o.JobIds != nil { + qrJobIds = *o.JobIds + } + qJobIds := qrJobIds + if qJobIds != "" { + + if err := r.SetQueryParam("jobIds", qJobIds); err != nil { + return err + } + } + } + + if o.Tags != nil { + + // query param tags + var qrTags string + + if o.Tags != nil { + qrTags = *o.Tags + } + qTags := qrTags + if qTags != "" { + + if err := r.SetQueryParam("tags", qTags); err != nil { + return err + } + } + } + + if o.Workflows != nil { + + // query param workflows + var qrWorkflows string + + if o.Workflows != nil { + qrWorkflows = *o.Workflows + } + qWorkflows := qrWorkflows + if qWorkflows != "" { + + if err := r.SetQueryParam("workflows", qWorkflows); err != nil { + return err + } + } + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} diff --git a/generated/client/jobs/get_jobs_events_responses.go b/generated/client/jobs/get_jobs_events_responses.go new file mode 100644 index 00000000..902a169c --- /dev/null +++ b/generated/client/jobs/get_jobs_events_responses.go @@ -0,0 +1,309 @@ +// Code generated by go-swagger; DO NOT EDIT. + +// SPDX-FileCopyrightText: 2023 Siemens AG +// +// SPDX-License-Identifier: Apache-2.0 +// + +package jobs + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "fmt" + "io" + + "github.com/go-openapi/runtime" + "github.com/go-openapi/strfmt" + "github.com/siemens/wfx/generated/model" +) + +// GetJobsEventsReader is a Reader for the GetJobsEvents structure. +type GetJobsEventsReader struct { + formats strfmt.Registry +} + +// ReadResponse reads a server response into the received o. +func (o *GetJobsEventsReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (interface{}, error) { + switch response.Code() { + case 200: + result := NewGetJobsEventsOK() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return result, nil + case 400: + result := NewGetJobsEventsBadRequest() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return nil, result + case 404: + result := NewGetJobsEventsNotFound() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return nil, result + default: + result := NewGetJobsEventsDefault(response.Code()) + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + if response.Code()/100 == 2 { + return result, nil + } + return nil, result + } +} + +// NewGetJobsEventsOK creates a GetJobsEventsOK with default headers values +func NewGetJobsEventsOK() *GetJobsEventsOK { + return &GetJobsEventsOK{} +} + +/* +GetJobsEventsOK describes a response with status code 200, with default header values. + +A stream of server-sent events +*/ +type GetJobsEventsOK struct { +} + +// IsSuccess returns true when this get jobs events o k response has a 2xx status code +func (o *GetJobsEventsOK) IsSuccess() bool { + return true +} + +// IsRedirect returns true when this get jobs events o k response has a 3xx status code +func (o *GetJobsEventsOK) IsRedirect() bool { + return false +} + +// IsClientError returns true when this get jobs events o k response has a 4xx status code +func (o *GetJobsEventsOK) IsClientError() bool { + return false +} + +// IsServerError returns true when this get jobs events o k response has a 5xx status code +func (o *GetJobsEventsOK) IsServerError() bool { + return false +} + +// IsCode returns true when this get jobs events o k response a status code equal to that given +func (o *GetJobsEventsOK) IsCode(code int) bool { + return code == 200 +} + +// Code gets the status code for the get jobs events o k response +func (o *GetJobsEventsOK) Code() int { + return 200 +} + +func (o *GetJobsEventsOK) Error() string { + return fmt.Sprintf("[GET /jobs/events][%d] getJobsEventsOK ", 200) +} + +func (o *GetJobsEventsOK) String() string { + return fmt.Sprintf("[GET /jobs/events][%d] getJobsEventsOK ", 200) +} + +func (o *GetJobsEventsOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + return nil +} + +// NewGetJobsEventsBadRequest creates a GetJobsEventsBadRequest with default headers values +func NewGetJobsEventsBadRequest() *GetJobsEventsBadRequest { + return &GetJobsEventsBadRequest{} +} + +/* +GetJobsEventsBadRequest describes a response with status code 400, with default header values. + +Bad Request +*/ +type GetJobsEventsBadRequest struct { + Payload *model.ErrorResponse +} + +// IsSuccess returns true when this get jobs events bad request response has a 2xx status code +func (o *GetJobsEventsBadRequest) IsSuccess() bool { + return false +} + +// IsRedirect returns true when this get jobs events bad request response has a 3xx status code +func (o *GetJobsEventsBadRequest) IsRedirect() bool { + return false +} + +// IsClientError returns true when this get jobs events bad request response has a 4xx status code +func (o *GetJobsEventsBadRequest) IsClientError() bool { + return true +} + +// IsServerError returns true when this get jobs events bad request response has a 5xx status code +func (o *GetJobsEventsBadRequest) IsServerError() bool { + return false +} + +// IsCode returns true when this get jobs events bad request response a status code equal to that given +func (o *GetJobsEventsBadRequest) IsCode(code int) bool { + return code == 400 +} + +// Code gets the status code for the get jobs events bad request response +func (o *GetJobsEventsBadRequest) Code() int { + return 400 +} + +func (o *GetJobsEventsBadRequest) Error() string { + return fmt.Sprintf("[GET /jobs/events][%d] getJobsEventsBadRequest %+v", 400, o.Payload) +} + +func (o *GetJobsEventsBadRequest) String() string { + return fmt.Sprintf("[GET /jobs/events][%d] getJobsEventsBadRequest %+v", 400, o.Payload) +} + +func (o *GetJobsEventsBadRequest) GetPayload() *model.ErrorResponse { + return o.Payload +} + +func (o *GetJobsEventsBadRequest) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + o.Payload = new(model.ErrorResponse) + + // response payload + if err := consumer.Consume(response.Body(), o.Payload); err != nil && err != io.EOF { + return err + } + + return nil +} + +// NewGetJobsEventsNotFound creates a GetJobsEventsNotFound with default headers values +func NewGetJobsEventsNotFound() *GetJobsEventsNotFound { + return &GetJobsEventsNotFound{} +} + +/* +GetJobsEventsNotFound describes a response with status code 404, with default header values. + +Not Found +*/ +type GetJobsEventsNotFound struct { + Payload *model.ErrorResponse +} + +// IsSuccess returns true when this get jobs events not found response has a 2xx status code +func (o *GetJobsEventsNotFound) IsSuccess() bool { + return false +} + +// IsRedirect returns true when this get jobs events not found response has a 3xx status code +func (o *GetJobsEventsNotFound) IsRedirect() bool { + return false +} + +// IsClientError returns true when this get jobs events not found response has a 4xx status code +func (o *GetJobsEventsNotFound) IsClientError() bool { + return true +} + +// IsServerError returns true when this get jobs events not found response has a 5xx status code +func (o *GetJobsEventsNotFound) IsServerError() bool { + return false +} + +// IsCode returns true when this get jobs events not found response a status code equal to that given +func (o *GetJobsEventsNotFound) IsCode(code int) bool { + return code == 404 +} + +// Code gets the status code for the get jobs events not found response +func (o *GetJobsEventsNotFound) Code() int { + return 404 +} + +func (o *GetJobsEventsNotFound) Error() string { + return fmt.Sprintf("[GET /jobs/events][%d] getJobsEventsNotFound %+v", 404, o.Payload) +} + +func (o *GetJobsEventsNotFound) String() string { + return fmt.Sprintf("[GET /jobs/events][%d] getJobsEventsNotFound %+v", 404, o.Payload) +} + +func (o *GetJobsEventsNotFound) GetPayload() *model.ErrorResponse { + return o.Payload +} + +func (o *GetJobsEventsNotFound) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + o.Payload = new(model.ErrorResponse) + + // response payload + if err := consumer.Consume(response.Body(), o.Payload); err != nil && err != io.EOF { + return err + } + + return nil +} + +// NewGetJobsEventsDefault creates a GetJobsEventsDefault with default headers values +func NewGetJobsEventsDefault(code int) *GetJobsEventsDefault { + return &GetJobsEventsDefault{ + _statusCode: code, + } +} + +/* +GetJobsEventsDefault describes a response with status code -1, with default header values. + +Other error with any status code and response body format +*/ +type GetJobsEventsDefault struct { + _statusCode int +} + +// IsSuccess returns true when this get jobs events default response has a 2xx status code +func (o *GetJobsEventsDefault) IsSuccess() bool { + return o._statusCode/100 == 2 +} + +// IsRedirect returns true when this get jobs events default response has a 3xx status code +func (o *GetJobsEventsDefault) IsRedirect() bool { + return o._statusCode/100 == 3 +} + +// IsClientError returns true when this get jobs events default response has a 4xx status code +func (o *GetJobsEventsDefault) IsClientError() bool { + return o._statusCode/100 == 4 +} + +// IsServerError returns true when this get jobs events default response has a 5xx status code +func (o *GetJobsEventsDefault) IsServerError() bool { + return o._statusCode/100 == 5 +} + +// IsCode returns true when this get jobs events default response a status code equal to that given +func (o *GetJobsEventsDefault) IsCode(code int) bool { + return o._statusCode == code +} + +// Code gets the status code for the get jobs events default response +func (o *GetJobsEventsDefault) Code() int { + return o._statusCode +} + +func (o *GetJobsEventsDefault) Error() string { + return fmt.Sprintf("[GET /jobs/events][%d] GetJobsEvents default ", o._statusCode) +} + +func (o *GetJobsEventsDefault) String() string { + return fmt.Sprintf("[GET /jobs/events][%d] GetJobsEvents default ", o._statusCode) +} + +func (o *GetJobsEventsDefault) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + return nil +} diff --git a/generated/client/jobs/jobs_client.go b/generated/client/jobs/jobs_client.go index a0965a56..fe87973d 100644 --- a/generated/client/jobs/jobs_client.go +++ b/generated/client/jobs/jobs_client.go @@ -39,6 +39,8 @@ type ClientService interface { GetJobs(params *GetJobsParams, opts ...ClientOption) (*GetJobsOK, error) + GetJobsEvents(params *GetJobsEventsParams, opts ...ClientOption) (*GetJobsEventsOK, error) + GetJobsID(params *GetJobsIDParams, opts ...ClientOption) (*GetJobsIDOK, error) GetJobsIDDefinition(params *GetJobsIDDefinitionParams, opts ...ClientOption) (*GetJobsIDDefinitionOK, error) @@ -180,6 +182,46 @@ func (a *Client) GetJobs(params *GetJobsParams, opts ...ClientOption) (*GetJobsO return nil, runtime.NewAPIError("unexpected success response: content available as default response in error", unexpectedSuccess, unexpectedSuccess.Code()) } +/* +GetJobsEvents subscribes to job related events such as status updates + +Obtain instant notifications when there are job changes matching the criteria. This endpoint utilizes server-sent events (SSE), where responses are "chunked" with double newline breaks. For example, a single event might look like this: +data: {"clientId":"example_client","state":"INSTALLING"}\n\n +*/ +func (a *Client) GetJobsEvents(params *GetJobsEventsParams, opts ...ClientOption) (*GetJobsEventsOK, error) { + // TODO: Validate the params before sending + if params == nil { + params = NewGetJobsEventsParams() + } + op := &runtime.ClientOperation{ + ID: "GetJobsEvents", + Method: "GET", + PathPattern: "/jobs/events", + ProducesMediaTypes: []string{"application/json", "text/event-stream"}, + ConsumesMediaTypes: []string{"application/json"}, + Schemes: []string{"http"}, + Params: params, + Reader: &GetJobsEventsReader{formats: a.formats}, + Context: params.Context, + Client: params.HTTPClient, + } + for _, opt := range opts { + opt(op) + } + + result, err := a.transport.Submit(op) + if err != nil { + return nil, err + } + success, ok := result.(*GetJobsEventsOK) + if ok { + return success, nil + } + // unexpected success response + unexpectedSuccess := result.(*GetJobsEventsDefault) + return nil, runtime.NewAPIError("unexpected success response: content available as default response in error", unexpectedSuccess, unexpectedSuccess.Code()) +} + /* GetJobsID jobs description for a given ID diff --git a/generated/model/job.go b/generated/model/job.go index cea3699c..4c6ded47 100644 --- a/generated/model/job.go +++ b/generated/model/job.go @@ -35,7 +35,7 @@ type Job struct { // The job's history. Last in, first out (LIFO). Array is truncated if its length exceeds the maximum allowed length. // Max Items: 8192 - History []*History `json:"history"` + History []*History `json:"history,omitempty"` // Unique job ID (wfx-generated) // Example: 3307e5cb-074c-49b7-99d4-5e61839a4c2d @@ -46,18 +46,18 @@ type Job struct { // Date and time (ISO8601) when the job was last modified (set by wfx) // Read Only: true // Format: date-time - Mtime strfmt.DateTime `json:"mtime,omitempty"` + Mtime *strfmt.DateTime `json:"mtime,omitempty"` // status Status *JobStatus `json:"status,omitempty"` - // Date and time (ISO8601) when the job was created (set by wfx) + // Date and time (ISO8601) when the job was created (set by wfx). Although stime conceptually always exists, it's nullable because we don't want to serialize stime in some cases (e.g. for job events). // Read Only: true // Format: date-time - Stime strfmt.DateTime `json:"stime,omitempty"` + Stime *strfmt.DateTime `json:"stime,omitempty"` // tags - Tags []string `json:"tags"` + Tags []string `json:"tags,omitempty"` // workflow Workflow *Workflow `json:"workflow,omitempty"` @@ -273,7 +273,7 @@ func (m *Job) contextValidateID(ctx context.Context, formats strfmt.Registry) er func (m *Job) contextValidateMtime(ctx context.Context, formats strfmt.Registry) error { - if err := validate.ReadOnly(ctx, "mtime", "body", strfmt.DateTime(m.Mtime)); err != nil { + if err := validate.ReadOnly(ctx, "mtime", "body", m.Mtime); err != nil { return err } @@ -303,7 +303,7 @@ func (m *Job) contextValidateStatus(ctx context.Context, formats strfmt.Registry func (m *Job) contextValidateStime(ctx context.Context, formats strfmt.Registry) error { - if err := validate.ReadOnly(ctx, "stime", "body", strfmt.DateTime(m.Stime)); err != nil { + if err := validate.ReadOnly(ctx, "stime", "body", m.Stime); err != nil { return err } diff --git a/generated/model/workflow.go b/generated/model/workflow.go index a9fb2fb3..2650a760 100644 --- a/generated/model/workflow.go +++ b/generated/model/workflow.go @@ -32,7 +32,7 @@ type Workflow struct { // groups // Max Items: 1024 - Groups []*Group `json:"groups"` + Groups []*Group `json:"groups,omitempty"` // User provided unique workflow name // Example: wfx.workflow.dau.direct @@ -43,16 +43,12 @@ type Workflow struct { Name string `json:"name"` // states - // Required: true // Max Items: 4096 - // Min Items: 1 - States []*State `json:"states"` + States []*State `json:"states,omitempty"` // transitions - // Required: true // Max Items: 16384 - // Min Items: 1 - Transitions []*Transition `json:"transitions"` + Transitions []*Transition `json:"transitions,omitempty"` } // Validate validates this workflow @@ -151,17 +147,12 @@ func (m *Workflow) validateName(formats strfmt.Registry) error { } func (m *Workflow) validateStates(formats strfmt.Registry) error { - - if err := validate.Required("states", "body", m.States); err != nil { - return err + if swag.IsZero(m.States) { // not required + return nil } iStatesSize := int64(len(m.States)) - if err := validate.MinItems("states", "body", iStatesSize, 1); err != nil { - return err - } - if err := validate.MaxItems("states", "body", iStatesSize, 4096); err != nil { return err } @@ -188,17 +179,12 @@ func (m *Workflow) validateStates(formats strfmt.Registry) error { } func (m *Workflow) validateTransitions(formats strfmt.Registry) error { - - if err := validate.Required("transitions", "body", m.Transitions); err != nil { - return err + if swag.IsZero(m.Transitions) { // not required + return nil } iTransitionsSize := int64(len(m.Transitions)) - if err := validate.MinItems("transitions", "body", iTransitionsSize, 1); err != nil { - return err - } - if err := validate.MaxItems("transitions", "body", iTransitionsSize, 16384); err != nil { return err } diff --git a/generated/northbound/restapi/configure_workflow_executor.go b/generated/northbound/restapi/configure_workflow_executor.go index fa1dc177..c10e7837 100644 --- a/generated/northbound/restapi/configure_workflow_executor.go +++ b/generated/northbound/restapi/configure_workflow_executor.go @@ -30,6 +30,8 @@ func ConfigureAPI(api *operations.WorkflowExecutorAPI) http.Handler { api.JSONProducer = producer.JSONProducer() + api.TextEventStreamProducer = producer.TextEventStreamProducer() + api.PreServerShutdown = func() {} return setupGlobalMiddleware(api.Serve(setupMiddlewares)) diff --git a/generated/northbound/restapi/doc.go b/generated/northbound/restapi/doc.go index ac633a29..5876cb2b 100644 --- a/generated/northbound/restapi/doc.go +++ b/generated/northbound/restapi/doc.go @@ -18,6 +18,7 @@ // // Produces: // - application/json +// - text/event-stream // // swagger:meta package restapi diff --git a/generated/northbound/restapi/embedded_spec.go b/generated/northbound/restapi/embedded_spec.go index bac98953..133a36e5 100644 --- a/generated/northbound/restapi/embedded_spec.go +++ b/generated/northbound/restapi/embedded_spec.go @@ -162,6 +162,89 @@ func init() { } } }, + "/jobs/events": { + "get": { + "description": "Obtain instant notifications when there are job changes matching the criteria. This endpoint utilizes server-sent events (SSE), where responses are \"chunked\" with double newline breaks. For example, a single event might look like this:\n data: {\"clientId\":\"example_client\",\"state\":\"INSTALLING\"}\\n\\n\n", + "produces": [ + "application/json", + "text/event-stream" + ], + "tags": [ + "jobs", + "northbound", + "southbound" + ], + "summary": "Subscribe to job-related events such as status updates", + "parameters": [ + { + "type": "string", + "description": "The job's clientId must be one of these clientIds (comma-separated).", + "name": "clientIds", + "in": "query" + }, + { + "type": "string", + "description": "The job's id must be one of these ids (comma-separated).", + "name": "jobIds", + "in": "query" + }, + { + "type": "string", + "description": "The job's workflow must be equal to one of the provided workflow names (comma-separated).", + "name": "workflows", + "in": "query" + }, + { + "type": "string", + "description": "A (comma-separated) list of tags to include into each job event. This can be used to aggregrate events from multiple wfx instances.\n", + "name": "tags", + "in": "query" + } + ], + "responses": { + "200": { + "description": "A stream of server-sent events" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + }, + "examples": { + "Error responses occurring at this operation for invalid requests": { + "errors": [ + { + "code": "wfx.jobTerminalState", + "logref": "916f0a913a3e4a52a96bd271e029c201", + "message": "The request was invalid because the job is in a terminal state" + } + ] + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/ErrorResponse" + }, + "examples": { + "Error responses occurring at this operation while updating a non-existent job": { + "errors": [ + { + "code": "wfx.jobNotFound", + "logref": "11cc67762090e15b79a1387eca65ba65", + "message": "Job ID was not found" + } + ] + } + } + }, + "default": { + "description": "Other error with any status code and response body format" + } + } + } + }, "/jobs/{id}": { "get": { "description": "Job description for a given ID\n", @@ -1086,7 +1169,8 @@ func init() { "maxItems": 8192, "items": { "$ref": "#/definitions/History" - } + }, + "x-omitempty": true }, "id": { "description": "Unique job ID (wfx-generated)", @@ -1100,15 +1184,17 @@ func init() { "description": "Date and time (ISO8601) when the job was last modified (set by wfx)", "type": "string", "format": "date-time", + "x-nullable": true, "readOnly": true }, "status": { "$ref": "#/definitions/JobStatus" }, "stime": { - "description": "Date and time (ISO8601) when the job was created (set by wfx)", + "description": "Date and time (ISO8601) when the job was created (set by wfx). Although stime conceptually always exists, it's nullable because we don't want to serialize stime in some cases (e.g. for job events).", "type": "string", "format": "date-time", + "x-nullable": true, "readOnly": true }, "tags": { @@ -1116,7 +1202,8 @@ func init() { "items": { "type": "string", "maxItems": 16 - } + }, + "x-omitempty": true }, "workflow": { "$ref": "#/definitions/Workflow" @@ -1331,9 +1418,7 @@ func init() { "Workflow": { "type": "object", "required": [ - "name", - "states", - "transitions" + "name" ], "properties": { "description": { @@ -1347,7 +1432,8 @@ func init() { "maxItems": 1024, "items": { "$ref": "#/definitions/Group" - } + }, + "x-omitempty": true }, "name": { "description": "User provided unique workflow name", @@ -1361,18 +1447,18 @@ func init() { "states": { "type": "array", "maxItems": 4096, - "minItems": 1, "items": { "$ref": "#/definitions/State" - } + }, + "x-omitempty": true }, "transitions": { "type": "array", "maxItems": 16384, - "minItems": 1, "items": { "$ref": "#/definitions/Transition" - } + }, + "x-omitempty": true } } } @@ -1536,6 +1622,11 @@ func init() { "logref": "11cc67762090e15b79a1387eca65ba65", "message": "Job ID was not found" }, + "jobTerminalStateError": { + "code": "wfx.jobTerminalState", + "logref": "916f0a913a3e4a52a96bd271e029c201", + "message": "The request was invalid because the job is in a terminal state" + }, "workflowInvalidError": { "code": "wfx.workflowInvalid", "logref": "18f57adc70dd79c7fb4f1246be8a6e04", @@ -1734,6 +1825,89 @@ func init() { } } }, + "/jobs/events": { + "get": { + "description": "Obtain instant notifications when there are job changes matching the criteria. This endpoint utilizes server-sent events (SSE), where responses are \"chunked\" with double newline breaks. For example, a single event might look like this:\n data: {\"clientId\":\"example_client\",\"state\":\"INSTALLING\"}\\n\\n\n", + "produces": [ + "application/json", + "text/event-stream" + ], + "tags": [ + "jobs", + "northbound", + "southbound" + ], + "summary": "Subscribe to job-related events such as status updates", + "parameters": [ + { + "type": "string", + "description": "The job's clientId must be one of these clientIds (comma-separated).", + "name": "clientIds", + "in": "query" + }, + { + "type": "string", + "description": "The job's id must be one of these ids (comma-separated).", + "name": "jobIds", + "in": "query" + }, + { + "type": "string", + "description": "The job's workflow must be equal to one of the provided workflow names (comma-separated).", + "name": "workflows", + "in": "query" + }, + { + "type": "string", + "description": "A (comma-separated) list of tags to include into each job event. This can be used to aggregrate events from multiple wfx instances.\n", + "name": "tags", + "in": "query" + } + ], + "responses": { + "200": { + "description": "A stream of server-sent events" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + }, + "examples": { + "Error responses occurring at this operation for invalid requests": { + "errors": [ + { + "code": "wfx.jobTerminalState", + "logref": "916f0a913a3e4a52a96bd271e029c201", + "message": "The request was invalid because the job is in a terminal state" + } + ] + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/ErrorResponse" + }, + "examples": { + "Error responses occurring at this operation while updating a non-existent job": { + "errors": [ + { + "code": "wfx.jobNotFound", + "logref": "11cc67762090e15b79a1387eca65ba65", + "message": "Job ID was not found" + } + ] + } + } + }, + "default": { + "description": "Other error with any status code and response body format" + } + } + } + }, "/jobs/{id}": { "get": { "description": "Job description for a given ID\n", @@ -2716,7 +2890,8 @@ func init() { "maxItems": 8192, "items": { "$ref": "#/definitions/History" - } + }, + "x-omitempty": true }, "id": { "description": "Unique job ID (wfx-generated)", @@ -2730,22 +2905,25 @@ func init() { "description": "Date and time (ISO8601) when the job was last modified (set by wfx)", "type": "string", "format": "date-time", + "x-nullable": true, "readOnly": true }, "status": { "$ref": "#/definitions/JobStatus" }, "stime": { - "description": "Date and time (ISO8601) when the job was created (set by wfx)", + "description": "Date and time (ISO8601) when the job was created (set by wfx). Although stime conceptually always exists, it's nullable because we don't want to serialize stime in some cases (e.g. for job events).", "type": "string", "format": "date-time", + "x-nullable": true, "readOnly": true }, "tags": { "type": "array", "items": { "type": "string" - } + }, + "x-omitempty": true }, "workflow": { "$ref": "#/definitions/Workflow" @@ -3007,9 +3185,7 @@ func init() { "Workflow": { "type": "object", "required": [ - "name", - "states", - "transitions" + "name" ], "properties": { "description": { @@ -3023,7 +3199,8 @@ func init() { "maxItems": 1024, "items": { "$ref": "#/definitions/Group" - } + }, + "x-omitempty": true }, "name": { "description": "User provided unique workflow name", @@ -3037,18 +3214,18 @@ func init() { "states": { "type": "array", "maxItems": 4096, - "minItems": 1, "items": { "$ref": "#/definitions/State" - } + }, + "x-omitempty": true }, "transitions": { "type": "array", "maxItems": 16384, - "minItems": 1, "items": { "$ref": "#/definitions/Transition" - } + }, + "x-omitempty": true } } } @@ -3212,6 +3389,11 @@ func init() { "logref": "11cc67762090e15b79a1387eca65ba65", "message": "Job ID was not found" }, + "jobTerminalStateError": { + "code": "wfx.jobTerminalState", + "logref": "916f0a913a3e4a52a96bd271e029c201", + "message": "The request was invalid because the job is in a terminal state" + }, "workflowInvalidError": { "code": "wfx.workflowInvalid", "logref": "18f57adc70dd79c7fb4f1246be8a6e04", diff --git a/generated/northbound/restapi/operations/northbound/get_jobs_events.go b/generated/northbound/restapi/operations/northbound/get_jobs_events.go new file mode 100644 index 00000000..98855ad5 --- /dev/null +++ b/generated/northbound/restapi/operations/northbound/get_jobs_events.go @@ -0,0 +1,65 @@ +// Code generated by go-swagger; DO NOT EDIT. + +// SPDX-FileCopyrightText: 2023 Siemens AG +// +// SPDX-License-Identifier: Apache-2.0 +// + +package northbound + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the generate command + +import ( + "net/http" + + "github.com/go-openapi/runtime/middleware" +) + +// GetJobsEventsHandlerFunc turns a function with the right signature into a get jobs events handler +type GetJobsEventsHandlerFunc func(GetJobsEventsParams) middleware.Responder + +// Handle executing the request and returning a response +func (fn GetJobsEventsHandlerFunc) Handle(params GetJobsEventsParams) middleware.Responder { + return fn(params) +} + +// GetJobsEventsHandler interface for that can handle valid get jobs events params +type GetJobsEventsHandler interface { + Handle(GetJobsEventsParams) middleware.Responder +} + +// NewGetJobsEvents creates a new http.Handler for the get jobs events operation +func NewGetJobsEvents(ctx *middleware.Context, handler GetJobsEventsHandler) *GetJobsEvents { + return &GetJobsEvents{Context: ctx, Handler: handler} +} + +/* + GetJobsEvents swagger:route GET /jobs/events northbound getJobsEvents + +# Subscribe to job-related events such as status updates + +Obtain instant notifications when there are job changes matching the criteria. This endpoint utilizes server-sent events (SSE), where responses are "chunked" with double newline breaks. For example, a single event might look like this: + + data: {"clientId":"example_client","state":"INSTALLING"}\n\n +*/ +type GetJobsEvents struct { + Context *middleware.Context + Handler GetJobsEventsHandler +} + +func (o *GetJobsEvents) ServeHTTP(rw http.ResponseWriter, r *http.Request) { + route, rCtx, _ := o.Context.RouteInfo(r) + if rCtx != nil { + *r = *rCtx + } + var Params = NewGetJobsEventsParams() + if err := o.Context.BindValidRequest(r, route, &Params); err != nil { // bind params + o.Context.Respond(rw, r, route.Produces, route, err) + return + } + + res := o.Handler.Handle(Params) // actually handle the request + o.Context.Respond(rw, r, route.Produces, route, res) + +} diff --git a/generated/northbound/restapi/operations/northbound/get_jobs_events_parameters.go b/generated/northbound/restapi/operations/northbound/get_jobs_events_parameters.go new file mode 100644 index 00000000..9d8d8f5d --- /dev/null +++ b/generated/northbound/restapi/operations/northbound/get_jobs_events_parameters.go @@ -0,0 +1,164 @@ +// Code generated by go-swagger; DO NOT EDIT. + +// SPDX-FileCopyrightText: 2023 Siemens AG +// +// SPDX-License-Identifier: Apache-2.0 +// + +package northbound + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "net/http" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + "github.com/go-openapi/runtime/middleware" + "github.com/go-openapi/strfmt" +) + +// NewGetJobsEventsParams creates a new GetJobsEventsParams object +// +// There are no default values defined in the spec. +func NewGetJobsEventsParams() GetJobsEventsParams { + + return GetJobsEventsParams{} +} + +// GetJobsEventsParams contains all the bound params for the get jobs events operation +// typically these are obtained from a http.Request +// +// swagger:parameters GetJobsEvents +type GetJobsEventsParams struct { + + // HTTP Request Object + HTTPRequest *http.Request `json:"-"` + + /*The job's clientId must be one of these clientIds (comma-separated). + In: query + */ + ClientIds *string + /*The job's id must be one of these ids (comma-separated). + In: query + */ + JobIds *string + /*A (comma-separated) list of tags to include into each job event. This can be used to aggregrate events from multiple wfx instances. + + In: query + */ + Tags *string + /*The job's workflow must be equal to one of the provided workflow names (comma-separated). + In: query + */ + Workflows *string +} + +// BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface +// for simple values it will use straight method calls. +// +// To ensure default values, the struct must have been initialized with NewGetJobsEventsParams() beforehand. +func (o *GetJobsEventsParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error { + var res []error + + o.HTTPRequest = r + + qs := runtime.Values(r.URL.Query()) + + qClientIds, qhkClientIds, _ := qs.GetOK("clientIds") + if err := o.bindClientIds(qClientIds, qhkClientIds, route.Formats); err != nil { + res = append(res, err) + } + + qJobIds, qhkJobIds, _ := qs.GetOK("jobIds") + if err := o.bindJobIds(qJobIds, qhkJobIds, route.Formats); err != nil { + res = append(res, err) + } + + qTags, qhkTags, _ := qs.GetOK("tags") + if err := o.bindTags(qTags, qhkTags, route.Formats); err != nil { + res = append(res, err) + } + + qWorkflows, qhkWorkflows, _ := qs.GetOK("workflows") + if err := o.bindWorkflows(qWorkflows, qhkWorkflows, route.Formats); err != nil { + res = append(res, err) + } + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +// bindClientIds binds and validates parameter ClientIds from query. +func (o *GetJobsEventsParams) bindClientIds(rawData []string, hasKey bool, formats strfmt.Registry) error { + var raw string + if len(rawData) > 0 { + raw = rawData[len(rawData)-1] + } + + // Required: false + // AllowEmptyValue: false + + if raw == "" { // empty values pass all other validations + return nil + } + o.ClientIds = &raw + + return nil +} + +// bindJobIds binds and validates parameter JobIds from query. +func (o *GetJobsEventsParams) bindJobIds(rawData []string, hasKey bool, formats strfmt.Registry) error { + var raw string + if len(rawData) > 0 { + raw = rawData[len(rawData)-1] + } + + // Required: false + // AllowEmptyValue: false + + if raw == "" { // empty values pass all other validations + return nil + } + o.JobIds = &raw + + return nil +} + +// bindTags binds and validates parameter Tags from query. +func (o *GetJobsEventsParams) bindTags(rawData []string, hasKey bool, formats strfmt.Registry) error { + var raw string + if len(rawData) > 0 { + raw = rawData[len(rawData)-1] + } + + // Required: false + // AllowEmptyValue: false + + if raw == "" { // empty values pass all other validations + return nil + } + o.Tags = &raw + + return nil +} + +// bindWorkflows binds and validates parameter Workflows from query. +func (o *GetJobsEventsParams) bindWorkflows(rawData []string, hasKey bool, formats strfmt.Registry) error { + var raw string + if len(rawData) > 0 { + raw = rawData[len(rawData)-1] + } + + // Required: false + // AllowEmptyValue: false + + if raw == "" { // empty values pass all other validations + return nil + } + o.Workflows = &raw + + return nil +} diff --git a/generated/northbound/restapi/operations/northbound/get_jobs_events_responses.go b/generated/northbound/restapi/operations/northbound/get_jobs_events_responses.go new file mode 100644 index 00000000..3c1da9e3 --- /dev/null +++ b/generated/northbound/restapi/operations/northbound/get_jobs_events_responses.go @@ -0,0 +1,172 @@ +// Code generated by go-swagger; DO NOT EDIT. + +// SPDX-FileCopyrightText: 2023 Siemens AG +// +// SPDX-License-Identifier: Apache-2.0 +// + +package northbound + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "net/http" + + "github.com/go-openapi/runtime" + "github.com/siemens/wfx/generated/model" +) + +// GetJobsEventsOKCode is the HTTP code returned for type GetJobsEventsOK +const GetJobsEventsOKCode int = 200 + +/* +GetJobsEventsOK A stream of server-sent events + +swagger:response getJobsEventsOK +*/ +type GetJobsEventsOK struct { +} + +// NewGetJobsEventsOK creates GetJobsEventsOK with default headers values +func NewGetJobsEventsOK() *GetJobsEventsOK { + + return &GetJobsEventsOK{} +} + +// WriteResponse to the client +func (o *GetJobsEventsOK) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) { + + rw.Header().Del(runtime.HeaderContentType) //Remove Content-Type on empty responses + + rw.WriteHeader(200) +} + +// GetJobsEventsBadRequestCode is the HTTP code returned for type GetJobsEventsBadRequest +const GetJobsEventsBadRequestCode int = 400 + +/* +GetJobsEventsBadRequest Bad Request + +swagger:response getJobsEventsBadRequest +*/ +type GetJobsEventsBadRequest struct { + + /* + In: Body + */ + Payload *model.ErrorResponse `json:"body,omitempty"` +} + +// NewGetJobsEventsBadRequest creates GetJobsEventsBadRequest with default headers values +func NewGetJobsEventsBadRequest() *GetJobsEventsBadRequest { + + return &GetJobsEventsBadRequest{} +} + +// WithPayload adds the payload to the get jobs events bad request response +func (o *GetJobsEventsBadRequest) WithPayload(payload *model.ErrorResponse) *GetJobsEventsBadRequest { + o.Payload = payload + return o +} + +// SetPayload sets the payload to the get jobs events bad request response +func (o *GetJobsEventsBadRequest) SetPayload(payload *model.ErrorResponse) { + o.Payload = payload +} + +// WriteResponse to the client +func (o *GetJobsEventsBadRequest) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) { + + rw.WriteHeader(400) + if o.Payload != nil { + payload := o.Payload + if err := producer.Produce(rw, payload); err != nil { + panic(err) // let the recovery middleware deal with this + } + } +} + +// GetJobsEventsNotFoundCode is the HTTP code returned for type GetJobsEventsNotFound +const GetJobsEventsNotFoundCode int = 404 + +/* +GetJobsEventsNotFound Not Found + +swagger:response getJobsEventsNotFound +*/ +type GetJobsEventsNotFound struct { + + /* + In: Body + */ + Payload *model.ErrorResponse `json:"body,omitempty"` +} + +// NewGetJobsEventsNotFound creates GetJobsEventsNotFound with default headers values +func NewGetJobsEventsNotFound() *GetJobsEventsNotFound { + + return &GetJobsEventsNotFound{} +} + +// WithPayload adds the payload to the get jobs events not found response +func (o *GetJobsEventsNotFound) WithPayload(payload *model.ErrorResponse) *GetJobsEventsNotFound { + o.Payload = payload + return o +} + +// SetPayload sets the payload to the get jobs events not found response +func (o *GetJobsEventsNotFound) SetPayload(payload *model.ErrorResponse) { + o.Payload = payload +} + +// WriteResponse to the client +func (o *GetJobsEventsNotFound) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) { + + rw.WriteHeader(404) + if o.Payload != nil { + payload := o.Payload + if err := producer.Produce(rw, payload); err != nil { + panic(err) // let the recovery middleware deal with this + } + } +} + +/* +GetJobsEventsDefault Other error with any status code and response body format + +swagger:response getJobsEventsDefault +*/ +type GetJobsEventsDefault struct { + _statusCode int +} + +// NewGetJobsEventsDefault creates GetJobsEventsDefault with default headers values +func NewGetJobsEventsDefault(code int) *GetJobsEventsDefault { + if code <= 0 { + code = 500 + } + + return &GetJobsEventsDefault{ + _statusCode: code, + } +} + +// WithStatusCode adds the status to the get jobs events default response +func (o *GetJobsEventsDefault) WithStatusCode(code int) *GetJobsEventsDefault { + o._statusCode = code + return o +} + +// SetStatusCode sets the status to the get jobs events default response +func (o *GetJobsEventsDefault) SetStatusCode(code int) { + o._statusCode = code +} + +// WriteResponse to the client +func (o *GetJobsEventsDefault) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) { + + rw.Header().Del(runtime.HeaderContentType) //Remove Content-Type on empty responses + + rw.WriteHeader(o._statusCode) +} diff --git a/generated/northbound/restapi/operations/northbound/get_jobs_events_urlbuilder.go b/generated/northbound/restapi/operations/northbound/get_jobs_events_urlbuilder.go new file mode 100644 index 00000000..11810c10 --- /dev/null +++ b/generated/northbound/restapi/operations/northbound/get_jobs_events_urlbuilder.go @@ -0,0 +1,135 @@ +// Code generated by go-swagger; DO NOT EDIT. + +// SPDX-FileCopyrightText: 2023 Siemens AG +// +// SPDX-License-Identifier: Apache-2.0 +// + +package northbound + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the generate command + +import ( + "errors" + "net/url" + golangswaggerpaths "path" +) + +// GetJobsEventsURL generates an URL for the get jobs events operation +type GetJobsEventsURL struct { + ClientIds *string + JobIds *string + Tags *string + Workflows *string + + _basePath string + // avoid unkeyed usage + _ struct{} +} + +// WithBasePath sets the base path for this url builder, only required when it's different from the +// base path specified in the swagger spec. +// When the value of the base path is an empty string +func (o *GetJobsEventsURL) WithBasePath(bp string) *GetJobsEventsURL { + o.SetBasePath(bp) + return o +} + +// SetBasePath sets the base path for this url builder, only required when it's different from the +// base path specified in the swagger spec. +// When the value of the base path is an empty string +func (o *GetJobsEventsURL) SetBasePath(bp string) { + o._basePath = bp +} + +// Build a url path and query string +func (o *GetJobsEventsURL) Build() (*url.URL, error) { + var _result url.URL + + var _path = "/jobs/events" + + _basePath := o._basePath + if _basePath == "" { + _basePath = "/api/wfx/v1" + } + _result.Path = golangswaggerpaths.Join(_basePath, _path) + + qs := make(url.Values) + + var clientIdsQ string + if o.ClientIds != nil { + clientIdsQ = *o.ClientIds + } + if clientIdsQ != "" { + qs.Set("clientIds", clientIdsQ) + } + + var jobIdsQ string + if o.JobIds != nil { + jobIdsQ = *o.JobIds + } + if jobIdsQ != "" { + qs.Set("jobIds", jobIdsQ) + } + + var tagsQ string + if o.Tags != nil { + tagsQ = *o.Tags + } + if tagsQ != "" { + qs.Set("tags", tagsQ) + } + + var workflowsQ string + if o.Workflows != nil { + workflowsQ = *o.Workflows + } + if workflowsQ != "" { + qs.Set("workflows", workflowsQ) + } + + _result.RawQuery = qs.Encode() + + return &_result, nil +} + +// Must is a helper function to panic when the url builder returns an error +func (o *GetJobsEventsURL) Must(u *url.URL, err error) *url.URL { + if err != nil { + panic(err) + } + if u == nil { + panic("url can't be nil") + } + return u +} + +// String returns the string representation of the path with query string +func (o *GetJobsEventsURL) String() string { + return o.Must(o.Build()).String() +} + +// BuildFull builds a full url with scheme, host, path and query string +func (o *GetJobsEventsURL) BuildFull(scheme, host string) (*url.URL, error) { + if scheme == "" { + return nil, errors.New("scheme is required for a full url on GetJobsEventsURL") + } + if host == "" { + return nil, errors.New("host is required for a full url on GetJobsEventsURL") + } + + base, err := o.Build() + if err != nil { + return nil, err + } + + base.Scheme = scheme + base.Host = host + return base, nil +} + +// StringFull returns the string representation of a complete url +func (o *GetJobsEventsURL) StringFull(scheme, host string) string { + return o.Must(o.BuildFull(scheme, host)).String() +} diff --git a/generated/northbound/restapi/operations/workflow_executor_api.go b/generated/northbound/restapi/operations/workflow_executor_api.go index d3b5e11a..3e655556 100644 --- a/generated/northbound/restapi/operations/workflow_executor_api.go +++ b/generated/northbound/restapi/operations/workflow_executor_api.go @@ -12,6 +12,7 @@ package operations import ( "fmt" + "io" "net/http" "strings" @@ -48,6 +49,9 @@ func NewWorkflowExecutorAPI(spec *loads.Document) *WorkflowExecutorAPI { JSONConsumer: runtime.JSONConsumer(), JSONProducer: runtime.JSONProducer(), + TextEventStreamProducer: runtime.ProducerFunc(func(w io.Writer, data interface{}) error { + return errors.NotImplemented("textEventStream producer has not yet been implemented") + }), NorthboundDeleteJobsIDHandler: northbound.DeleteJobsIDHandlerFunc(func(params northbound.DeleteJobsIDParams) middleware.Responder { return middleware.NotImplemented("operation northbound.DeleteJobsID has not yet been implemented") @@ -61,6 +65,9 @@ func NewWorkflowExecutorAPI(spec *loads.Document) *WorkflowExecutorAPI { NorthboundGetJobsHandler: northbound.GetJobsHandlerFunc(func(params northbound.GetJobsParams) middleware.Responder { return middleware.NotImplemented("operation northbound.GetJobs has not yet been implemented") }), + NorthboundGetJobsEventsHandler: northbound.GetJobsEventsHandlerFunc(func(params northbound.GetJobsEventsParams) middleware.Responder { + return middleware.NotImplemented("operation northbound.GetJobsEvents has not yet been implemented") + }), NorthboundGetJobsIDHandler: northbound.GetJobsIDHandlerFunc(func(params northbound.GetJobsIDParams) middleware.Responder { return middleware.NotImplemented("operation northbound.GetJobsID has not yet been implemented") }), @@ -129,6 +136,9 @@ type WorkflowExecutorAPI struct { // JSONProducer registers a producer for the following mime types: // - application/json JSONProducer runtime.Producer + // TextEventStreamProducer registers a producer for the following mime types: + // - text/event-stream + TextEventStreamProducer runtime.Producer // NorthboundDeleteJobsIDHandler sets the operation handler for the delete jobs ID operation NorthboundDeleteJobsIDHandler northbound.DeleteJobsIDHandler @@ -138,6 +148,8 @@ type WorkflowExecutorAPI struct { NorthboundDeleteWorkflowsNameHandler northbound.DeleteWorkflowsNameHandler // NorthboundGetJobsHandler sets the operation handler for the get jobs operation NorthboundGetJobsHandler northbound.GetJobsHandler + // NorthboundGetJobsEventsHandler sets the operation handler for the get jobs events operation + NorthboundGetJobsEventsHandler northbound.GetJobsEventsHandler // NorthboundGetJobsIDHandler sets the operation handler for the get jobs ID operation NorthboundGetJobsIDHandler northbound.GetJobsIDHandler // NorthboundGetJobsIDDefinitionHandler sets the operation handler for the get jobs ID definition operation @@ -236,6 +248,9 @@ func (o *WorkflowExecutorAPI) Validate() error { if o.JSONProducer == nil { unregistered = append(unregistered, "JSONProducer") } + if o.TextEventStreamProducer == nil { + unregistered = append(unregistered, "TextEventStreamProducer") + } if o.NorthboundDeleteJobsIDHandler == nil { unregistered = append(unregistered, "northbound.DeleteJobsIDHandler") @@ -249,6 +264,9 @@ func (o *WorkflowExecutorAPI) Validate() error { if o.NorthboundGetJobsHandler == nil { unregistered = append(unregistered, "northbound.GetJobsHandler") } + if o.NorthboundGetJobsEventsHandler == nil { + unregistered = append(unregistered, "northbound.GetJobsEventsHandler") + } if o.NorthboundGetJobsIDHandler == nil { unregistered = append(unregistered, "northbound.GetJobsIDHandler") } @@ -330,6 +348,8 @@ func (o *WorkflowExecutorAPI) ProducersFor(mediaTypes []string) map[string]runti switch mt { case "application/json": result["application/json"] = o.JSONProducer + case "text/event-stream": + result["text/event-stream"] = o.TextEventStreamProducer } if p, ok := o.customProducers[mt]; ok { @@ -389,6 +409,10 @@ func (o *WorkflowExecutorAPI) initHandlerCache() { if o.handlers["GET"] == nil { o.handlers["GET"] = make(map[string]http.Handler) } + o.handlers["GET"]["/jobs/events"] = northbound.NewGetJobsEvents(o.context, o.NorthboundGetJobsEventsHandler) + if o.handlers["GET"] == nil { + o.handlers["GET"] = make(map[string]http.Handler) + } o.handlers["GET"]["/jobs/{id}"] = northbound.NewGetJobsID(o.context, o.NorthboundGetJobsIDHandler) if o.handlers["GET"] == nil { o.handlers["GET"] = make(map[string]http.Handler) diff --git a/generated/southbound/restapi/configure_workflow_executor.go b/generated/southbound/restapi/configure_workflow_executor.go index e86c8f38..13570198 100644 --- a/generated/southbound/restapi/configure_workflow_executor.go +++ b/generated/southbound/restapi/configure_workflow_executor.go @@ -30,6 +30,8 @@ func ConfigureAPI(api *operations.WorkflowExecutorAPI) http.Handler { api.JSONProducer = producer.JSONProducer() + api.TextEventStreamProducer = producer.TextEventStreamProducer() + api.PreServerShutdown = func() {} return setupGlobalMiddleware(api.Serve(setupMiddlewares)) diff --git a/generated/southbound/restapi/doc.go b/generated/southbound/restapi/doc.go index ac633a29..5876cb2b 100644 --- a/generated/southbound/restapi/doc.go +++ b/generated/southbound/restapi/doc.go @@ -18,6 +18,7 @@ // // Produces: // - application/json +// - text/event-stream // // swagger:meta package restapi diff --git a/generated/southbound/restapi/embedded_spec.go b/generated/southbound/restapi/embedded_spec.go index bac98953..133a36e5 100644 --- a/generated/southbound/restapi/embedded_spec.go +++ b/generated/southbound/restapi/embedded_spec.go @@ -162,6 +162,89 @@ func init() { } } }, + "/jobs/events": { + "get": { + "description": "Obtain instant notifications when there are job changes matching the criteria. This endpoint utilizes server-sent events (SSE), where responses are \"chunked\" with double newline breaks. For example, a single event might look like this:\n data: {\"clientId\":\"example_client\",\"state\":\"INSTALLING\"}\\n\\n\n", + "produces": [ + "application/json", + "text/event-stream" + ], + "tags": [ + "jobs", + "northbound", + "southbound" + ], + "summary": "Subscribe to job-related events such as status updates", + "parameters": [ + { + "type": "string", + "description": "The job's clientId must be one of these clientIds (comma-separated).", + "name": "clientIds", + "in": "query" + }, + { + "type": "string", + "description": "The job's id must be one of these ids (comma-separated).", + "name": "jobIds", + "in": "query" + }, + { + "type": "string", + "description": "The job's workflow must be equal to one of the provided workflow names (comma-separated).", + "name": "workflows", + "in": "query" + }, + { + "type": "string", + "description": "A (comma-separated) list of tags to include into each job event. This can be used to aggregrate events from multiple wfx instances.\n", + "name": "tags", + "in": "query" + } + ], + "responses": { + "200": { + "description": "A stream of server-sent events" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + }, + "examples": { + "Error responses occurring at this operation for invalid requests": { + "errors": [ + { + "code": "wfx.jobTerminalState", + "logref": "916f0a913a3e4a52a96bd271e029c201", + "message": "The request was invalid because the job is in a terminal state" + } + ] + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/ErrorResponse" + }, + "examples": { + "Error responses occurring at this operation while updating a non-existent job": { + "errors": [ + { + "code": "wfx.jobNotFound", + "logref": "11cc67762090e15b79a1387eca65ba65", + "message": "Job ID was not found" + } + ] + } + } + }, + "default": { + "description": "Other error with any status code and response body format" + } + } + } + }, "/jobs/{id}": { "get": { "description": "Job description for a given ID\n", @@ -1086,7 +1169,8 @@ func init() { "maxItems": 8192, "items": { "$ref": "#/definitions/History" - } + }, + "x-omitempty": true }, "id": { "description": "Unique job ID (wfx-generated)", @@ -1100,15 +1184,17 @@ func init() { "description": "Date and time (ISO8601) when the job was last modified (set by wfx)", "type": "string", "format": "date-time", + "x-nullable": true, "readOnly": true }, "status": { "$ref": "#/definitions/JobStatus" }, "stime": { - "description": "Date and time (ISO8601) when the job was created (set by wfx)", + "description": "Date and time (ISO8601) when the job was created (set by wfx). Although stime conceptually always exists, it's nullable because we don't want to serialize stime in some cases (e.g. for job events).", "type": "string", "format": "date-time", + "x-nullable": true, "readOnly": true }, "tags": { @@ -1116,7 +1202,8 @@ func init() { "items": { "type": "string", "maxItems": 16 - } + }, + "x-omitempty": true }, "workflow": { "$ref": "#/definitions/Workflow" @@ -1331,9 +1418,7 @@ func init() { "Workflow": { "type": "object", "required": [ - "name", - "states", - "transitions" + "name" ], "properties": { "description": { @@ -1347,7 +1432,8 @@ func init() { "maxItems": 1024, "items": { "$ref": "#/definitions/Group" - } + }, + "x-omitempty": true }, "name": { "description": "User provided unique workflow name", @@ -1361,18 +1447,18 @@ func init() { "states": { "type": "array", "maxItems": 4096, - "minItems": 1, "items": { "$ref": "#/definitions/State" - } + }, + "x-omitempty": true }, "transitions": { "type": "array", "maxItems": 16384, - "minItems": 1, "items": { "$ref": "#/definitions/Transition" - } + }, + "x-omitempty": true } } } @@ -1536,6 +1622,11 @@ func init() { "logref": "11cc67762090e15b79a1387eca65ba65", "message": "Job ID was not found" }, + "jobTerminalStateError": { + "code": "wfx.jobTerminalState", + "logref": "916f0a913a3e4a52a96bd271e029c201", + "message": "The request was invalid because the job is in a terminal state" + }, "workflowInvalidError": { "code": "wfx.workflowInvalid", "logref": "18f57adc70dd79c7fb4f1246be8a6e04", @@ -1734,6 +1825,89 @@ func init() { } } }, + "/jobs/events": { + "get": { + "description": "Obtain instant notifications when there are job changes matching the criteria. This endpoint utilizes server-sent events (SSE), where responses are \"chunked\" with double newline breaks. For example, a single event might look like this:\n data: {\"clientId\":\"example_client\",\"state\":\"INSTALLING\"}\\n\\n\n", + "produces": [ + "application/json", + "text/event-stream" + ], + "tags": [ + "jobs", + "northbound", + "southbound" + ], + "summary": "Subscribe to job-related events such as status updates", + "parameters": [ + { + "type": "string", + "description": "The job's clientId must be one of these clientIds (comma-separated).", + "name": "clientIds", + "in": "query" + }, + { + "type": "string", + "description": "The job's id must be one of these ids (comma-separated).", + "name": "jobIds", + "in": "query" + }, + { + "type": "string", + "description": "The job's workflow must be equal to one of the provided workflow names (comma-separated).", + "name": "workflows", + "in": "query" + }, + { + "type": "string", + "description": "A (comma-separated) list of tags to include into each job event. This can be used to aggregrate events from multiple wfx instances.\n", + "name": "tags", + "in": "query" + } + ], + "responses": { + "200": { + "description": "A stream of server-sent events" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + }, + "examples": { + "Error responses occurring at this operation for invalid requests": { + "errors": [ + { + "code": "wfx.jobTerminalState", + "logref": "916f0a913a3e4a52a96bd271e029c201", + "message": "The request was invalid because the job is in a terminal state" + } + ] + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/ErrorResponse" + }, + "examples": { + "Error responses occurring at this operation while updating a non-existent job": { + "errors": [ + { + "code": "wfx.jobNotFound", + "logref": "11cc67762090e15b79a1387eca65ba65", + "message": "Job ID was not found" + } + ] + } + } + }, + "default": { + "description": "Other error with any status code and response body format" + } + } + } + }, "/jobs/{id}": { "get": { "description": "Job description for a given ID\n", @@ -2716,7 +2890,8 @@ func init() { "maxItems": 8192, "items": { "$ref": "#/definitions/History" - } + }, + "x-omitempty": true }, "id": { "description": "Unique job ID (wfx-generated)", @@ -2730,22 +2905,25 @@ func init() { "description": "Date and time (ISO8601) when the job was last modified (set by wfx)", "type": "string", "format": "date-time", + "x-nullable": true, "readOnly": true }, "status": { "$ref": "#/definitions/JobStatus" }, "stime": { - "description": "Date and time (ISO8601) when the job was created (set by wfx)", + "description": "Date and time (ISO8601) when the job was created (set by wfx). Although stime conceptually always exists, it's nullable because we don't want to serialize stime in some cases (e.g. for job events).", "type": "string", "format": "date-time", + "x-nullable": true, "readOnly": true }, "tags": { "type": "array", "items": { "type": "string" - } + }, + "x-omitempty": true }, "workflow": { "$ref": "#/definitions/Workflow" @@ -3007,9 +3185,7 @@ func init() { "Workflow": { "type": "object", "required": [ - "name", - "states", - "transitions" + "name" ], "properties": { "description": { @@ -3023,7 +3199,8 @@ func init() { "maxItems": 1024, "items": { "$ref": "#/definitions/Group" - } + }, + "x-omitempty": true }, "name": { "description": "User provided unique workflow name", @@ -3037,18 +3214,18 @@ func init() { "states": { "type": "array", "maxItems": 4096, - "minItems": 1, "items": { "$ref": "#/definitions/State" - } + }, + "x-omitempty": true }, "transitions": { "type": "array", "maxItems": 16384, - "minItems": 1, "items": { "$ref": "#/definitions/Transition" - } + }, + "x-omitempty": true } } } @@ -3212,6 +3389,11 @@ func init() { "logref": "11cc67762090e15b79a1387eca65ba65", "message": "Job ID was not found" }, + "jobTerminalStateError": { + "code": "wfx.jobTerminalState", + "logref": "916f0a913a3e4a52a96bd271e029c201", + "message": "The request was invalid because the job is in a terminal state" + }, "workflowInvalidError": { "code": "wfx.workflowInvalid", "logref": "18f57adc70dd79c7fb4f1246be8a6e04", diff --git a/generated/southbound/restapi/operations/southbound/get_jobs_events.go b/generated/southbound/restapi/operations/southbound/get_jobs_events.go new file mode 100644 index 00000000..4fc069e3 --- /dev/null +++ b/generated/southbound/restapi/operations/southbound/get_jobs_events.go @@ -0,0 +1,65 @@ +// Code generated by go-swagger; DO NOT EDIT. + +// SPDX-FileCopyrightText: 2023 Siemens AG +// +// SPDX-License-Identifier: Apache-2.0 +// + +package southbound + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the generate command + +import ( + "net/http" + + "github.com/go-openapi/runtime/middleware" +) + +// GetJobsEventsHandlerFunc turns a function with the right signature into a get jobs events handler +type GetJobsEventsHandlerFunc func(GetJobsEventsParams) middleware.Responder + +// Handle executing the request and returning a response +func (fn GetJobsEventsHandlerFunc) Handle(params GetJobsEventsParams) middleware.Responder { + return fn(params) +} + +// GetJobsEventsHandler interface for that can handle valid get jobs events params +type GetJobsEventsHandler interface { + Handle(GetJobsEventsParams) middleware.Responder +} + +// NewGetJobsEvents creates a new http.Handler for the get jobs events operation +func NewGetJobsEvents(ctx *middleware.Context, handler GetJobsEventsHandler) *GetJobsEvents { + return &GetJobsEvents{Context: ctx, Handler: handler} +} + +/* + GetJobsEvents swagger:route GET /jobs/events southbound getJobsEvents + +# Subscribe to job-related events such as status updates + +Obtain instant notifications when there are job changes matching the criteria. This endpoint utilizes server-sent events (SSE), where responses are "chunked" with double newline breaks. For example, a single event might look like this: + + data: {"clientId":"example_client","state":"INSTALLING"}\n\n +*/ +type GetJobsEvents struct { + Context *middleware.Context + Handler GetJobsEventsHandler +} + +func (o *GetJobsEvents) ServeHTTP(rw http.ResponseWriter, r *http.Request) { + route, rCtx, _ := o.Context.RouteInfo(r) + if rCtx != nil { + *r = *rCtx + } + var Params = NewGetJobsEventsParams() + if err := o.Context.BindValidRequest(r, route, &Params); err != nil { // bind params + o.Context.Respond(rw, r, route.Produces, route, err) + return + } + + res := o.Handler.Handle(Params) // actually handle the request + o.Context.Respond(rw, r, route.Produces, route, res) + +} diff --git a/generated/southbound/restapi/operations/southbound/get_jobs_events_parameters.go b/generated/southbound/restapi/operations/southbound/get_jobs_events_parameters.go new file mode 100644 index 00000000..dbefd672 --- /dev/null +++ b/generated/southbound/restapi/operations/southbound/get_jobs_events_parameters.go @@ -0,0 +1,164 @@ +// Code generated by go-swagger; DO NOT EDIT. + +// SPDX-FileCopyrightText: 2023 Siemens AG +// +// SPDX-License-Identifier: Apache-2.0 +// + +package southbound + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "net/http" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + "github.com/go-openapi/runtime/middleware" + "github.com/go-openapi/strfmt" +) + +// NewGetJobsEventsParams creates a new GetJobsEventsParams object +// +// There are no default values defined in the spec. +func NewGetJobsEventsParams() GetJobsEventsParams { + + return GetJobsEventsParams{} +} + +// GetJobsEventsParams contains all the bound params for the get jobs events operation +// typically these are obtained from a http.Request +// +// swagger:parameters GetJobsEvents +type GetJobsEventsParams struct { + + // HTTP Request Object + HTTPRequest *http.Request `json:"-"` + + /*The job's clientId must be one of these clientIds (comma-separated). + In: query + */ + ClientIds *string + /*The job's id must be one of these ids (comma-separated). + In: query + */ + JobIds *string + /*A (comma-separated) list of tags to include into each job event. This can be used to aggregrate events from multiple wfx instances. + + In: query + */ + Tags *string + /*The job's workflow must be equal to one of the provided workflow names (comma-separated). + In: query + */ + Workflows *string +} + +// BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface +// for simple values it will use straight method calls. +// +// To ensure default values, the struct must have been initialized with NewGetJobsEventsParams() beforehand. +func (o *GetJobsEventsParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error { + var res []error + + o.HTTPRequest = r + + qs := runtime.Values(r.URL.Query()) + + qClientIds, qhkClientIds, _ := qs.GetOK("clientIds") + if err := o.bindClientIds(qClientIds, qhkClientIds, route.Formats); err != nil { + res = append(res, err) + } + + qJobIds, qhkJobIds, _ := qs.GetOK("jobIds") + if err := o.bindJobIds(qJobIds, qhkJobIds, route.Formats); err != nil { + res = append(res, err) + } + + qTags, qhkTags, _ := qs.GetOK("tags") + if err := o.bindTags(qTags, qhkTags, route.Formats); err != nil { + res = append(res, err) + } + + qWorkflows, qhkWorkflows, _ := qs.GetOK("workflows") + if err := o.bindWorkflows(qWorkflows, qhkWorkflows, route.Formats); err != nil { + res = append(res, err) + } + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +// bindClientIds binds and validates parameter ClientIds from query. +func (o *GetJobsEventsParams) bindClientIds(rawData []string, hasKey bool, formats strfmt.Registry) error { + var raw string + if len(rawData) > 0 { + raw = rawData[len(rawData)-1] + } + + // Required: false + // AllowEmptyValue: false + + if raw == "" { // empty values pass all other validations + return nil + } + o.ClientIds = &raw + + return nil +} + +// bindJobIds binds and validates parameter JobIds from query. +func (o *GetJobsEventsParams) bindJobIds(rawData []string, hasKey bool, formats strfmt.Registry) error { + var raw string + if len(rawData) > 0 { + raw = rawData[len(rawData)-1] + } + + // Required: false + // AllowEmptyValue: false + + if raw == "" { // empty values pass all other validations + return nil + } + o.JobIds = &raw + + return nil +} + +// bindTags binds and validates parameter Tags from query. +func (o *GetJobsEventsParams) bindTags(rawData []string, hasKey bool, formats strfmt.Registry) error { + var raw string + if len(rawData) > 0 { + raw = rawData[len(rawData)-1] + } + + // Required: false + // AllowEmptyValue: false + + if raw == "" { // empty values pass all other validations + return nil + } + o.Tags = &raw + + return nil +} + +// bindWorkflows binds and validates parameter Workflows from query. +func (o *GetJobsEventsParams) bindWorkflows(rawData []string, hasKey bool, formats strfmt.Registry) error { + var raw string + if len(rawData) > 0 { + raw = rawData[len(rawData)-1] + } + + // Required: false + // AllowEmptyValue: false + + if raw == "" { // empty values pass all other validations + return nil + } + o.Workflows = &raw + + return nil +} diff --git a/generated/southbound/restapi/operations/southbound/get_jobs_events_responses.go b/generated/southbound/restapi/operations/southbound/get_jobs_events_responses.go new file mode 100644 index 00000000..e2916bd0 --- /dev/null +++ b/generated/southbound/restapi/operations/southbound/get_jobs_events_responses.go @@ -0,0 +1,172 @@ +// Code generated by go-swagger; DO NOT EDIT. + +// SPDX-FileCopyrightText: 2023 Siemens AG +// +// SPDX-License-Identifier: Apache-2.0 +// + +package southbound + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "net/http" + + "github.com/go-openapi/runtime" + "github.com/siemens/wfx/generated/model" +) + +// GetJobsEventsOKCode is the HTTP code returned for type GetJobsEventsOK +const GetJobsEventsOKCode int = 200 + +/* +GetJobsEventsOK A stream of server-sent events + +swagger:response getJobsEventsOK +*/ +type GetJobsEventsOK struct { +} + +// NewGetJobsEventsOK creates GetJobsEventsOK with default headers values +func NewGetJobsEventsOK() *GetJobsEventsOK { + + return &GetJobsEventsOK{} +} + +// WriteResponse to the client +func (o *GetJobsEventsOK) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) { + + rw.Header().Del(runtime.HeaderContentType) //Remove Content-Type on empty responses + + rw.WriteHeader(200) +} + +// GetJobsEventsBadRequestCode is the HTTP code returned for type GetJobsEventsBadRequest +const GetJobsEventsBadRequestCode int = 400 + +/* +GetJobsEventsBadRequest Bad Request + +swagger:response getJobsEventsBadRequest +*/ +type GetJobsEventsBadRequest struct { + + /* + In: Body + */ + Payload *model.ErrorResponse `json:"body,omitempty"` +} + +// NewGetJobsEventsBadRequest creates GetJobsEventsBadRequest with default headers values +func NewGetJobsEventsBadRequest() *GetJobsEventsBadRequest { + + return &GetJobsEventsBadRequest{} +} + +// WithPayload adds the payload to the get jobs events bad request response +func (o *GetJobsEventsBadRequest) WithPayload(payload *model.ErrorResponse) *GetJobsEventsBadRequest { + o.Payload = payload + return o +} + +// SetPayload sets the payload to the get jobs events bad request response +func (o *GetJobsEventsBadRequest) SetPayload(payload *model.ErrorResponse) { + o.Payload = payload +} + +// WriteResponse to the client +func (o *GetJobsEventsBadRequest) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) { + + rw.WriteHeader(400) + if o.Payload != nil { + payload := o.Payload + if err := producer.Produce(rw, payload); err != nil { + panic(err) // let the recovery middleware deal with this + } + } +} + +// GetJobsEventsNotFoundCode is the HTTP code returned for type GetJobsEventsNotFound +const GetJobsEventsNotFoundCode int = 404 + +/* +GetJobsEventsNotFound Not Found + +swagger:response getJobsEventsNotFound +*/ +type GetJobsEventsNotFound struct { + + /* + In: Body + */ + Payload *model.ErrorResponse `json:"body,omitempty"` +} + +// NewGetJobsEventsNotFound creates GetJobsEventsNotFound with default headers values +func NewGetJobsEventsNotFound() *GetJobsEventsNotFound { + + return &GetJobsEventsNotFound{} +} + +// WithPayload adds the payload to the get jobs events not found response +func (o *GetJobsEventsNotFound) WithPayload(payload *model.ErrorResponse) *GetJobsEventsNotFound { + o.Payload = payload + return o +} + +// SetPayload sets the payload to the get jobs events not found response +func (o *GetJobsEventsNotFound) SetPayload(payload *model.ErrorResponse) { + o.Payload = payload +} + +// WriteResponse to the client +func (o *GetJobsEventsNotFound) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) { + + rw.WriteHeader(404) + if o.Payload != nil { + payload := o.Payload + if err := producer.Produce(rw, payload); err != nil { + panic(err) // let the recovery middleware deal with this + } + } +} + +/* +GetJobsEventsDefault Other error with any status code and response body format + +swagger:response getJobsEventsDefault +*/ +type GetJobsEventsDefault struct { + _statusCode int +} + +// NewGetJobsEventsDefault creates GetJobsEventsDefault with default headers values +func NewGetJobsEventsDefault(code int) *GetJobsEventsDefault { + if code <= 0 { + code = 500 + } + + return &GetJobsEventsDefault{ + _statusCode: code, + } +} + +// WithStatusCode adds the status to the get jobs events default response +func (o *GetJobsEventsDefault) WithStatusCode(code int) *GetJobsEventsDefault { + o._statusCode = code + return o +} + +// SetStatusCode sets the status to the get jobs events default response +func (o *GetJobsEventsDefault) SetStatusCode(code int) { + o._statusCode = code +} + +// WriteResponse to the client +func (o *GetJobsEventsDefault) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) { + + rw.Header().Del(runtime.HeaderContentType) //Remove Content-Type on empty responses + + rw.WriteHeader(o._statusCode) +} diff --git a/generated/southbound/restapi/operations/southbound/get_jobs_events_urlbuilder.go b/generated/southbound/restapi/operations/southbound/get_jobs_events_urlbuilder.go new file mode 100644 index 00000000..902f0d96 --- /dev/null +++ b/generated/southbound/restapi/operations/southbound/get_jobs_events_urlbuilder.go @@ -0,0 +1,135 @@ +// Code generated by go-swagger; DO NOT EDIT. + +// SPDX-FileCopyrightText: 2023 Siemens AG +// +// SPDX-License-Identifier: Apache-2.0 +// + +package southbound + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the generate command + +import ( + "errors" + "net/url" + golangswaggerpaths "path" +) + +// GetJobsEventsURL generates an URL for the get jobs events operation +type GetJobsEventsURL struct { + ClientIds *string + JobIds *string + Tags *string + Workflows *string + + _basePath string + // avoid unkeyed usage + _ struct{} +} + +// WithBasePath sets the base path for this url builder, only required when it's different from the +// base path specified in the swagger spec. +// When the value of the base path is an empty string +func (o *GetJobsEventsURL) WithBasePath(bp string) *GetJobsEventsURL { + o.SetBasePath(bp) + return o +} + +// SetBasePath sets the base path for this url builder, only required when it's different from the +// base path specified in the swagger spec. +// When the value of the base path is an empty string +func (o *GetJobsEventsURL) SetBasePath(bp string) { + o._basePath = bp +} + +// Build a url path and query string +func (o *GetJobsEventsURL) Build() (*url.URL, error) { + var _result url.URL + + var _path = "/jobs/events" + + _basePath := o._basePath + if _basePath == "" { + _basePath = "/api/wfx/v1" + } + _result.Path = golangswaggerpaths.Join(_basePath, _path) + + qs := make(url.Values) + + var clientIdsQ string + if o.ClientIds != nil { + clientIdsQ = *o.ClientIds + } + if clientIdsQ != "" { + qs.Set("clientIds", clientIdsQ) + } + + var jobIdsQ string + if o.JobIds != nil { + jobIdsQ = *o.JobIds + } + if jobIdsQ != "" { + qs.Set("jobIds", jobIdsQ) + } + + var tagsQ string + if o.Tags != nil { + tagsQ = *o.Tags + } + if tagsQ != "" { + qs.Set("tags", tagsQ) + } + + var workflowsQ string + if o.Workflows != nil { + workflowsQ = *o.Workflows + } + if workflowsQ != "" { + qs.Set("workflows", workflowsQ) + } + + _result.RawQuery = qs.Encode() + + return &_result, nil +} + +// Must is a helper function to panic when the url builder returns an error +func (o *GetJobsEventsURL) Must(u *url.URL, err error) *url.URL { + if err != nil { + panic(err) + } + if u == nil { + panic("url can't be nil") + } + return u +} + +// String returns the string representation of the path with query string +func (o *GetJobsEventsURL) String() string { + return o.Must(o.Build()).String() +} + +// BuildFull builds a full url with scheme, host, path and query string +func (o *GetJobsEventsURL) BuildFull(scheme, host string) (*url.URL, error) { + if scheme == "" { + return nil, errors.New("scheme is required for a full url on GetJobsEventsURL") + } + if host == "" { + return nil, errors.New("host is required for a full url on GetJobsEventsURL") + } + + base, err := o.Build() + if err != nil { + return nil, err + } + + base.Scheme = scheme + base.Host = host + return base, nil +} + +// StringFull returns the string representation of a complete url +func (o *GetJobsEventsURL) StringFull(scheme, host string) string { + return o.Must(o.BuildFull(scheme, host)).String() +} diff --git a/generated/southbound/restapi/operations/workflow_executor_api.go b/generated/southbound/restapi/operations/workflow_executor_api.go index ddc0bc3a..66692151 100644 --- a/generated/southbound/restapi/operations/workflow_executor_api.go +++ b/generated/southbound/restapi/operations/workflow_executor_api.go @@ -12,6 +12,7 @@ package operations import ( "fmt" + "io" "net/http" "strings" @@ -48,10 +49,16 @@ func NewWorkflowExecutorAPI(spec *loads.Document) *WorkflowExecutorAPI { JSONConsumer: runtime.JSONConsumer(), JSONProducer: runtime.JSONProducer(), + TextEventStreamProducer: runtime.ProducerFunc(func(w io.Writer, data interface{}) error { + return errors.NotImplemented("textEventStream producer has not yet been implemented") + }), SouthboundGetJobsHandler: southbound.GetJobsHandlerFunc(func(params southbound.GetJobsParams) middleware.Responder { return middleware.NotImplemented("operation southbound.GetJobs has not yet been implemented") }), + SouthboundGetJobsEventsHandler: southbound.GetJobsEventsHandlerFunc(func(params southbound.GetJobsEventsParams) middleware.Responder { + return middleware.NotImplemented("operation southbound.GetJobsEvents has not yet been implemented") + }), SouthboundGetJobsIDHandler: southbound.GetJobsIDHandlerFunc(func(params southbound.GetJobsIDParams) middleware.Responder { return middleware.NotImplemented("operation southbound.GetJobsID has not yet been implemented") }), @@ -111,9 +118,14 @@ type WorkflowExecutorAPI struct { // JSONProducer registers a producer for the following mime types: // - application/json JSONProducer runtime.Producer + // TextEventStreamProducer registers a producer for the following mime types: + // - text/event-stream + TextEventStreamProducer runtime.Producer // SouthboundGetJobsHandler sets the operation handler for the get jobs operation SouthboundGetJobsHandler southbound.GetJobsHandler + // SouthboundGetJobsEventsHandler sets the operation handler for the get jobs events operation + SouthboundGetJobsEventsHandler southbound.GetJobsEventsHandler // SouthboundGetJobsIDHandler sets the operation handler for the get jobs ID operation SouthboundGetJobsIDHandler southbound.GetJobsIDHandler // SouthboundGetJobsIDDefinitionHandler sets the operation handler for the get jobs ID definition operation @@ -206,10 +218,16 @@ func (o *WorkflowExecutorAPI) Validate() error { if o.JSONProducer == nil { unregistered = append(unregistered, "JSONProducer") } + if o.TextEventStreamProducer == nil { + unregistered = append(unregistered, "TextEventStreamProducer") + } if o.SouthboundGetJobsHandler == nil { unregistered = append(unregistered, "southbound.GetJobsHandler") } + if o.SouthboundGetJobsEventsHandler == nil { + unregistered = append(unregistered, "southbound.GetJobsEventsHandler") + } if o.SouthboundGetJobsIDHandler == nil { unregistered = append(unregistered, "southbound.GetJobsIDHandler") } @@ -282,6 +300,8 @@ func (o *WorkflowExecutorAPI) ProducersFor(mediaTypes []string) map[string]runti switch mt { case "application/json": result["application/json"] = o.JSONProducer + case "text/event-stream": + result["text/event-stream"] = o.TextEventStreamProducer } if p, ok := o.customProducers[mt]; ok { @@ -329,6 +349,10 @@ func (o *WorkflowExecutorAPI) initHandlerCache() { if o.handlers["GET"] == nil { o.handlers["GET"] = make(map[string]http.Handler) } + o.handlers["GET"]["/jobs/events"] = southbound.NewGetJobsEvents(o.context, o.SouthboundGetJobsEventsHandler) + if o.handlers["GET"] == nil { + o.handlers["GET"] = make(map[string]http.Handler) + } o.handlers["GET"]["/jobs/{id}"] = southbound.NewGetJobsID(o.context, o.SouthboundGetJobsIDHandler) if o.handlers["GET"] == nil { o.handlers["GET"] = make(map[string]http.Handler) diff --git a/go.mod b/go.mod index d418309a..fc115b3b 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,7 @@ require ( github.com/mattn/go-sqlite3 v1.14.18 github.com/muesli/mango-cobra v1.2.0 github.com/muesli/roff v0.1.0 + github.com/olebedev/emitter v0.0.0-20230411050614-349169dec2ba github.com/rs/cors v1.10.1 github.com/rs/zerolog v1.31.0 github.com/spf13/cobra v1.8.0 @@ -37,6 +38,7 @@ require ( github.com/steinfletcher/apitest v1.5.15 github.com/steinfletcher/apitest-jsonpath v1.7.2 github.com/stretchr/testify v1.8.4 + github.com/tmaxmax/go-sse v0.6.0 github.com/tsenart/vegeta/v12 v12.11.1 github.com/yourbasic/graph v0.0.0-20210606180040-8ecfec1c2869 go.uber.org/automaxprocs v1.5.3 @@ -83,11 +85,12 @@ require ( go.opentelemetry.io/otel/trace v1.16.0 // indirect go.uber.org/atomic v1.11.0 // indirect golang.org/x/crypto v0.14.0 // indirect - golang.org/x/mod v0.12.0 // indirect + golang.org/x/mod v0.13.0 // indirect golang.org/x/text v0.13.0 // indirect ) require ( + github.com/cenkalti/backoff/v4 v4.2.1 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/go-openapi/analysis v0.21.4 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -104,6 +107,7 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.mongodb.org/mongo-driver v1.12.0 // indirect go.opentelemetry.io/otel/metric v1.16.0 // indirect + golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect golang.org/x/net v0.17.0 // indirect golang.org/x/sync v0.3.0 // indirect golang.org/x/sys v0.15.0 // indirect diff --git a/go.sum b/go.sum index 132b31a3..d414e78d 100644 --- a/go.sum +++ b/go.sum @@ -25,6 +25,8 @@ github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef/go.mod h1:W github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/bmizerany/perks v0.0.0-20230307044200-03f9df79da1e h1:mWOqoK5jV13ChKf/aF3plwQ96laasTJgZi4f1aSOu+M= +github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= +github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cnf/structhash v0.0.0-20201127153200-e1b16c1ebc08 h1:ox2F0PSMlrAAiAdknSRMDrAr8mfxPCfSZolH+/qQnyQ= github.com/cnf/structhash v0.0.0-20201127153200-e1b16c1ebc08/go.mod h1:pCxVEbcm3AMg7ejXyorUXi6HQCzOIBf7zEDVPtw0/U4= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= @@ -185,6 +187,8 @@ github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/olebedev/emitter v0.0.0-20230411050614-349169dec2ba h1:/Q5vvLs180BFH7u+Nakdrr1B9O9RAxVaIurFQy0c8QQ= +github.com/olebedev/emitter v0.0.0-20230411050614-349169dec2ba/go.mod h1:eT2/Pcsim3XBjbvldGiJBvvgiqZkAFyiOJJsDKXs/ts= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM= github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= @@ -232,6 +236,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= +github.com/tmaxmax/go-sse v0.6.0 h1:FHt1n2ljccxnn+hWcflzbQqTyGgYIkyO0p/ap4w6IG0= +github.com/tmaxmax/go-sse v0.6.0/go.mod h1:WQsByT1/dnQCLgQw3639eARXhNhg+4EFdm2fLnj8e0c= github.com/tsenart/go-tsz v0.0.0-20180814235614-0bd30b3df1c3 h1:pcQGQzTwCg//7FgVywqge1sW9Yf8VMsMdG58MI5kd8s= github.com/tsenart/go-tsz v0.0.0-20180814235614-0bd30b3df1c3/go.mod h1:SWZznP1z5Ki7hDT2ioqiFKEse8K9tU2OUvaRI0NeGQo= github.com/tsenart/vegeta/v12 v12.11.1 h1:Rbwe7Zxr7sJ+BDTReemeQalYPvKiSV+O7nwmUs20B3E= @@ -271,10 +277,11 @@ golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0 golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= -golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY= +golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= @@ -314,7 +321,7 @@ golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= +golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.0.0-20181121035319-3f7ecaa7e8ca h1:PupagGYwj8+I4ubCxcmcBRk3VlUWtTg5huQpZR9flmE= diff --git a/internal/errutil/wrap_test.go b/internal/errutil/wrap_test.go index 2e580cf7..40f018f2 100644 --- a/internal/errutil/wrap_test.go +++ b/internal/errutil/wrap_test.go @@ -19,18 +19,24 @@ func TestWrap2(t *testing.T) { t.Parallel() t.Run("no error", func(t *testing.T) { + t.Parallel() + val, err := Wrap2("foo", nil) assert.Equal(t, "foo", val) assert.Nil(t, err) }) t.Run("error", func(t *testing.T) { + t.Parallel() + val, err := Wrap2[any](nil, errors.New("test")) assert.Nil(t, val) assert.NotNil(t, err) }) t.Run("both", func(t *testing.T) { + t.Parallel() + val, err := Wrap2("foo", errors.New("test")) assert.Equal(t, "foo", val) assert.NotNil(t, err) diff --git a/internal/handler/job/create.go b/internal/handler/job/create.go index 55b9b298..eb0fc85e 100644 --- a/internal/handler/job/create.go +++ b/internal/handler/job/create.go @@ -18,6 +18,7 @@ import ( "github.com/go-openapi/strfmt" "github.com/siemens/wfx/generated/model" "github.com/siemens/wfx/internal/handler/job/definition" + "github.com/siemens/wfx/internal/handler/job/events" "github.com/siemens/wfx/internal/workflow" "github.com/siemens/wfx/middleware/logging" "github.com/siemens/wfx/persistence" @@ -26,7 +27,6 @@ import ( func CreateJob(ctx context.Context, storage persistence.Storage, request *model.JobRequest) (*model.Job, error) { log := logging.LoggerFromCtx(ctx) contextLogger := log.With().Str("clientId", request.ClientID).Str("name", request.Workflow).Logger() - contextLogger.Debug().Msg("Creating new job") wf, err := storage.GetWorkflow(ctx, request.Workflow) if err != nil { @@ -45,8 +45,8 @@ func CreateJob(ctx context.Context, storage persistence.Storage, request *model. job := model.Job{ ClientID: request.ClientID, Workflow: wf, - Mtime: now, - Stime: now, + Mtime: &now, + Stime: &now, Status: &model.JobStatus{ ClientID: request.ClientID, State: initialState, @@ -68,6 +68,12 @@ func CreateJob(ctx context.Context, storage persistence.Storage, request *model. return nil, fault.Wrap(err, ftag.With(ftag.Internal)) } + _ = events.PublishEvent(ctx, &events.JobEvent{ + Ctime: strfmt.DateTime(time.Now()), + Action: events.ActionCreate, + Job: createdJob, + }) + contextLogger.Info().Str("id", job.ID).Msg("Created new job") return createdJob, nil } diff --git a/internal/handler/job/create_test.go b/internal/handler/job/create_test.go index af3854f1..c604a184 100644 --- a/internal/handler/job/create_test.go +++ b/internal/handler/job/create_test.go @@ -13,6 +13,7 @@ import ( "testing" "github.com/siemens/wfx/generated/model" + "github.com/siemens/wfx/internal/handler/job/events" "github.com/siemens/wfx/internal/persistence/entgo" "github.com/siemens/wfx/persistence" "github.com/siemens/wfx/workflow/dau" @@ -25,13 +26,32 @@ func TestCreateJob(t *testing.T) { wf := createDirectWorkflow(t, db) job, err := CreateJob(context.Background(), db, &model.JobRequest{ - ClientID: "klaus", + ClientID: "foo", Workflow: wf.Name, }) assert.NoError(t, err) assert.NotEmpty(t, job.Status.DefinitionHash) } +func TestCreateJob_Notification(t *testing.T) { + db := newInMemoryDB(t) + wf := createDirectWorkflow(t, db) + + ch, err := events.AddSubscriber(context.Background(), events.FilterParams{}, nil) + require.NoError(t, err) + + job, err := CreateJob(context.Background(), db, &model.JobRequest{ + ClientID: "foo", + Workflow: wf.Name, + }) + require.NoError(t, err) + + ev := <-ch + jobEvent := ev.Args[0].(*events.JobEvent) + assert.Equal(t, events.ActionCreate, jobEvent.Action) + assert.Equal(t, job.ID, jobEvent.Job.ID) +} + func TestFindInitial(t *testing.T) { wf := dau.DirectWorkflow() initial := findInitial(wf) diff --git a/internal/handler/job/definition/update.go b/internal/handler/job/definition/update.go index 49c0ccd1..34c502b0 100644 --- a/internal/handler/job/definition/update.go +++ b/internal/handler/job/definition/update.go @@ -12,10 +12,13 @@ import ( "context" "crypto/sha256" "fmt" + "time" "github.com/Southclaws/fault" "github.com/cnf/structhash" + "github.com/go-openapi/strfmt" "github.com/siemens/wfx/generated/model" + "github.com/siemens/wfx/internal/handler/job/events" "github.com/siemens/wfx/middleware/logging" "github.com/siemens/wfx/persistence" ) @@ -39,6 +42,20 @@ func Update(ctx context.Context, storage persistence.Storage, jobID string, defi return nil, fault.Wrap(err) } + _ = events.PublishEvent(ctx, &events.JobEvent{ + Ctime: strfmt.DateTime(time.Now()), + Action: events.ActionUpdateDefinition, + Job: &model.Job{ + ID: result.ID, + ClientID: result.ClientID, + Workflow: &model.Workflow{Name: job.Workflow.Name}, + Definition: result.Definition, + Status: &model.JobStatus{ + DefinitionHash: result.Status.DefinitionHash, + }, + }, + }) + contextLogger.Info().Msg("Updated job definition") return result.Definition, nil } diff --git a/internal/handler/job/definition/update_test.go b/internal/handler/job/definition/update_test.go index f555a9df..d0f4b70c 100644 --- a/internal/handler/job/definition/update_test.go +++ b/internal/handler/job/definition/update_test.go @@ -14,6 +14,7 @@ import ( "github.com/Southclaws/fault/ftag" "github.com/siemens/wfx/generated/model" + "github.com/siemens/wfx/internal/handler/job/events" "github.com/siemens/wfx/persistence" "github.com/siemens/wfx/workflow/dau" "github.com/stretchr/testify/assert" @@ -42,6 +43,9 @@ func TestUpdateJobDefinition(t *testing.T) { oldDefinitionHash := job.Status.DefinitionHash assert.NotEmpty(t, oldDefinitionHash) + ch, err := events.AddSubscriber(context.Background(), events.FilterParams{}, nil) + require.NoError(t, err) + newDefinition := map[string]any{ "foo": "baz", } @@ -55,6 +59,11 @@ func TestUpdateJobDefinition(t *testing.T) { assert.NoError(t, err) assert.NotEqual(t, oldDefinitionHash, job.Status.DefinitionHash) } + + ev := <-ch + jobEvent := ev.Args[0].(*events.JobEvent) + assert.Equal(t, events.ActionUpdateDefinition, jobEvent.Action) + assert.Equal(t, job.ID, jobEvent.Job.ID) } func TestUpdateJobDefinition_NotFound(t *testing.T) { diff --git a/internal/handler/job/delete.go b/internal/handler/job/delete.go index a2dca282..7baf34be 100644 --- a/internal/handler/job/delete.go +++ b/internal/handler/job/delete.go @@ -10,18 +10,40 @@ package job import ( "context" + "time" "github.com/Southclaws/fault" + "github.com/go-openapi/strfmt" + "github.com/siemens/wfx/generated/model" + "github.com/siemens/wfx/internal/handler/job/events" "github.com/siemens/wfx/middleware/logging" "github.com/siemens/wfx/persistence" ) func DeleteJob(ctx context.Context, storage persistence.Storage, jobID string) error { log := logging.LoggerFromCtx(ctx) + + // we have to fetch the job because we need the `ClientID` and `Workflow` for + // the job event notification + job, err := storage.GetJob(ctx, jobID, persistence.FetchParams{History: false}) + if err != nil { + return fault.Wrap(err) + } + if err := storage.DeleteJob(ctx, jobID); err != nil { - log.Err(err).Str("id", jobID).Msg("Failed to delete job") return fault.Wrap(err) } + + _ = events.PublishEvent(ctx, &events.JobEvent{ + Ctime: strfmt.DateTime(time.Now()), + Action: events.ActionDelete, + Job: &model.Job{ + ID: jobID, + ClientID: job.ClientID, + Workflow: &model.Workflow{Name: job.Workflow.Name}, + }, + }) + log.Info().Str("id", jobID).Msg("Deleted job") return nil } diff --git a/internal/handler/job/delete_test.go b/internal/handler/job/delete_test.go index 525adde6..99212cf1 100644 --- a/internal/handler/job/delete_test.go +++ b/internal/handler/job/delete_test.go @@ -10,10 +10,15 @@ package job import ( "context" + "errors" "testing" "github.com/Southclaws/fault/ftag" + "github.com/siemens/wfx/generated/model" + "github.com/siemens/wfx/internal/handler/job/events" + "github.com/siemens/wfx/persistence" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestDeleteJob(t *testing.T) { @@ -22,10 +27,18 @@ func TestDeleteJob(t *testing.T) { tmpJob := newValidJob("abc", "INSTALLING") job, err := db.CreateJob(context.Background(), &tmpJob) - assert.NoError(t, err) + require.NoError(t, err) + + ch, err := events.AddSubscriber(context.Background(), events.FilterParams{}, nil) + require.NoError(t, err) err = DeleteJob(context.Background(), db, job.ID) - assert.NoError(t, err) + require.NoError(t, err) + + ev := <-ch + jobEvent := ev.Args[0].(*events.JobEvent) + assert.Equal(t, events.ActionDelete, jobEvent.Action) + assert.Equal(t, job.ID, jobEvent.Job.ID) } func TestDeleteJob_NotFound(t *testing.T) { @@ -33,3 +46,13 @@ func TestDeleteJob_NotFound(t *testing.T) { err := DeleteJob(context.Background(), db, "42") assert.Equal(t, ftag.NotFound, ftag.Get(err)) } + +func TestDeleteJob_Error(t *testing.T) { + dbMock := persistence.NewMockStorage(t) + ctx := context.Background() + jobID := "42" + dbMock.EXPECT().GetJob(ctx, jobID, persistence.FetchParams{History: false}).Return(&model.Job{ID: jobID}, nil) + dbMock.EXPECT().DeleteJob(ctx, jobID).Return(errors.New("something went wrong")) + err := DeleteJob(ctx, dbMock, jobID) + assert.Equal(t, ftag.Internal, ftag.Get(err)) +} diff --git a/internal/handler/job/events/events.go b/internal/handler/job/events/events.go new file mode 100644 index 00000000..1d5e2117 --- /dev/null +++ b/internal/handler/job/events/events.go @@ -0,0 +1,143 @@ +package events + +/* + * SPDX-FileCopyrightText: 2023 Siemens AG + * + * SPDX-License-Identifier: Apache-2.0 + * + * Author: Michael Adler + */ + +import ( + "context" + + "github.com/go-openapi/strfmt" + "github.com/google/uuid" + "github.com/olebedev/emitter" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/siemens/wfx/generated/model" + "github.com/siemens/wfx/middleware/logging" +) + +type JobEvent struct { + // Ctime is the time when the event was created + Ctime strfmt.DateTime `json:"ctime"` + Action Action `json:"action"` + Job *model.Job `json:"job"` + Tags []string `json:"tags"` +} + +type FilterParams struct { + JobIDs []string + ClientIDs []string + Workflows []string +} + +type Action string + +const ( + ActionCreate Action = "CREATE" + ActionDelete Action = "DELETE" + ActionAddTags Action = "ADD_TAGS" + ActionDeleteTags Action = "DELETE_TAGS" + ActionUpdateStatus Action = "UPDATE_STATUS" + ActionUpdateDefinition Action = "UPDATE_DEFINITION" +) + +// NOTE: The number 32 for capacity is arbitrary; suggestions for a different value are welcome, particularly if supported by evidence. +var e *emitter.Emitter = emitter.New(32) + +// AddSubscriber adds a new subscriber to receive job events filtered based on the provided filterParams. +func AddSubscriber(ctx context.Context, filter FilterParams, tags []string) (<-chan emitter.Event, error) { + log := logging.LoggerFromCtx(ctx) + + // for logging purposes + subscriberID := uuid.New().String() + + log.Info(). + Str("subscriberID", subscriberID). + Dict("filterParams", zerolog.Dict(). + Strs("clientIDs", filter.ClientIDs). + Strs("jobIDs", filter.JobIDs). + Strs("workflows", filter.Workflows)). + Strs("tags", tags). + Msg("Adding new subscriber for job events") + + if filter := filter.createEventFilter(subscriberID, tags); filter != nil { + return e.On("*", filter), nil + } + return e.On("*"), nil +} + +// ShutdownSubscribers disconnects all subscribers. +func ShutdownSubscribers() { + for _, topic := range e.Topics() { + log.Debug().Str("topic", topic).Msg("Closing subscribers") + e.Off(topic) + } + log.Info().Msg("Unsubscribed all subscribers") +} + +// SubscriberCount counts the total number of subscribers across all topics. +func SubscriberCount() int { + count := 0 + for _, topic := range e.Topics() { + count += len(e.Listeners(topic)) + } + return count +} + +// PublishEvent publishes a new event. It returns a channel which can be used +// to wait for the delivery of the event to all listeners. +func PublishEvent(ctx context.Context, event *JobEvent) chan struct{} { + log := logging.LoggerFromCtx(ctx) + log.Debug(). + Str("jobID", event.Job.ID). + Str("action", string(event.Action)).Msg("Publishing event") + return e.Emit(event.Job.ID, event) +} + +func (filter FilterParams) createEventFilter(subscriberID string, tags []string) func(*emitter.Event) { + // special case: no filters means "catch-all" + if len(filter.JobIDs) == 0 && len(filter.ClientIDs) == 0 && len(filter.Workflows) == 0 { + return nil + } + + // build hash sets for faster lookup + jobIDSet := make(map[string]any, len(filter.JobIDs)) + for _, s := range filter.JobIDs { + jobIDSet[s] = nil + } + clientIDSet := make(map[string]any, len(filter.ClientIDs)) + for _, s := range filter.ClientIDs { + clientIDSet[s] = nil + } + workflowSet := make(map[string]any, len(filter.Workflows)) + for _, s := range filter.Workflows { + workflowSet[s] = nil + } + return func(ev *emitter.Event) { + event := ev.Args[0].(*JobEvent) + job := event.Job + // check if we shall notify the client about the event + _, interested := jobIDSet[job.ID] + if !interested { + _, interested = clientIDSet[job.ClientID] + } + if !interested && job.Workflow != nil { + _, interested = workflowSet[job.Workflow.Name] + } + + if interested { + // apply tags + log.Debug().Strs("tags", tags).Msg("Applying tags to event notification") + event.Tags = tags + } else { + log.Debug(). + Str("subscriberID", subscriberID). + Str("jobID", job.ID).Msg("Skipping event notification") + emitter.Void(ev) + } + } +} diff --git a/internal/handler/job/events/events_test.go b/internal/handler/job/events/events_test.go new file mode 100644 index 00000000..a9b1e799 --- /dev/null +++ b/internal/handler/job/events/events_test.go @@ -0,0 +1,94 @@ +package events + +/* + * SPDX-FileCopyrightText: 2023 Siemens AG + * + * SPDX-License-Identifier: Apache-2.0 + * + * Author: Michael Adler + */ + +import ( + "context" + "testing" + + "github.com/olebedev/emitter" + "github.com/siemens/wfx/generated/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAddSubscriberAndShutdown(t *testing.T) { + for i := 1; i <= 2; i++ { + _, err := AddSubscriber(context.Background(), FilterParams{JobIDs: []string{"42"}}, nil) + require.NoError(t, err) + assert.Equal(t, i, SubscriberCount()) + } + ShutdownSubscribers() + assert.Equal(t, 0, SubscriberCount()) +} + +func TestFiltering(t *testing.T) { + job1 := model.Job{ID: "1", Workflow: &model.Workflow{Name: "workflow"}, Status: &model.JobStatus{State: "INITIAL"}} + job2 := model.Job{ID: "2", Workflow: &model.Workflow{Name: "workflow"}, Status: &model.JobStatus{State: "INITIAL"}} + + ctx := context.Background() + ch1, _ := AddSubscriber(ctx, FilterParams{JobIDs: []string{job1.ID}}, nil) + ch2, _ := AddSubscriber(ctx, FilterParams{JobIDs: []string{job2.ID}}, nil) + chCombined, _ := AddSubscriber(ctx, FilterParams{JobIDs: []string{job1.ID, job2.ID}}, nil) + chAll, _ := AddSubscriber(ctx, FilterParams{}, nil) // no filter should receive all events + + job1.Status.State = "FOO" + <-PublishEvent(context.Background(), &JobEvent{Action: ActionUpdateStatus, Job: &job1}) + + job2.Status.State = "BAR" + <-PublishEvent(context.Background(), &JobEvent{Action: ActionUpdateStatus, Job: &job2}) + + { + ev := <-ch1 + actual := ev.Args[0].(*JobEvent) + assert.Equal(t, "FOO", actual.Job.Status.State) + + // check there is nothing else + select { + case <-ch1: + assert.Fail(t, "Received unexpected event") + default: + // nothing there, good + } + } + { + ev := <-ch2 + actual := ev.Args[0].(*JobEvent) + assert.Equal(t, "BAR", actual.Job.Status.State) + + // check there is nothing else + select { + case <-ch2: + assert.Fail(t, "Received unexpected event") + default: + // nothing there, good + } + } + { + for _, ch := range []<-chan emitter.Event{chCombined, chAll} { + ev := <-ch + actual := ev.Args[0].(*JobEvent) + assert.Equal(t, "FOO", actual.Job.Status.State) + + ev = <-ch + actual = ev.Args[0].(*JobEvent) + assert.Equal(t, "BAR", actual.Job.Status.State) + + // check there is nothing else + select { + case <-ch: + assert.Fail(t, "Received unexpected event") + default: + // nothing there, good + } + } + } + + ShutdownSubscribers() +} diff --git a/internal/handler/job/events/main_test.go b/internal/handler/job/events/main_test.go new file mode 100644 index 00000000..e52bf7ae --- /dev/null +++ b/internal/handler/job/events/main_test.go @@ -0,0 +1,19 @@ +package events + +/* + * SPDX-FileCopyrightText: 2023 Siemens AG + * + * SPDX-License-Identifier: Apache-2.0 + * + * Author: Michael Adler + */ + +import ( + "testing" + + "go.uber.org/goleak" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} diff --git a/internal/handler/job/get.go b/internal/handler/job/get.go index 666f3c44..c382df1c 100644 --- a/internal/handler/job/get.go +++ b/internal/handler/job/get.go @@ -18,16 +18,11 @@ import ( ) func GetJob(ctx context.Context, storage persistence.Storage, id string, history bool) (*model.Job, error) { - log := logging.LoggerFromCtx(ctx) - contextLogger := log.With(). - Str("id", id). - Bool("history", history). - Logger() - contextLogger.Debug().Msg("Fetching job") - fetchParams := persistence.FetchParams{History: history} job, err := storage.GetJob(ctx, id, fetchParams) if err != nil { + log := logging.LoggerFromCtx(ctx) + log.Error().Str("id", id).Bool("history", history).Err(err).Msg("Failed to get job from storage") return nil, fault.Wrap(err) } return job, nil diff --git a/internal/handler/job/get_test.go b/internal/handler/job/get_test.go index f672a655..8ba8a3f1 100644 --- a/internal/handler/job/get_test.go +++ b/internal/handler/job/get_test.go @@ -27,7 +27,7 @@ func TestGetJob(t *testing.T) { var jobID string { job, err := CreateJob(context.Background(), db, &model.JobRequest{ - ClientID: "klaus", + ClientID: "foo", Workflow: wf.Name, Definition: map[string]interface{}{"foo": "bar"}, }) @@ -38,8 +38,8 @@ func TestGetJob(t *testing.T) { job, err := GetJob(context.Background(), db, jobID, false) assert.NoError(t, err) assert.Equal(t, jobID, job.ID) - assert.Equal(t, job.Mtime, job.Stime) - assert.GreaterOrEqual(t, time.Time(job.Mtime).UnixMicro(), now.UnixMicro()) + assert.Equal(t, *job.Mtime, *job.Stime) + assert.GreaterOrEqual(t, time.Time(*job.Mtime).UnixMicro(), now.UnixMicro()) assert.Equal(t, "adc1cfc1577119ba2a0852133340088390c1103bdf82d8102970d3e6c53ec10b", job.Status.DefinitionHash) } diff --git a/internal/handler/job/status/get_test.go b/internal/handler/job/status/get_test.go index e4375a1a..3daff170 100644 --- a/internal/handler/job/status/get_test.go +++ b/internal/handler/job/status/get_test.go @@ -28,7 +28,7 @@ func TestGetJobStatus(t *testing.T) { require.NoError(t, err) tmpJob := model.Job{ - ClientID: "klaus", + ClientID: "foo", Workflow: wf, Status: &model.JobStatus{State: "CREATED"}, } diff --git a/internal/handler/job/status/update.go b/internal/handler/job/status/update.go index ef388ea8..c82e6cf4 100644 --- a/internal/handler/job/status/update.go +++ b/internal/handler/job/status/update.go @@ -11,10 +11,13 @@ package status import ( "context" "fmt" + "time" "github.com/Southclaws/fault" "github.com/Southclaws/fault/ftag" + "github.com/go-openapi/strfmt" "github.com/siemens/wfx/generated/model" + "github.com/siemens/wfx/internal/handler/job/events" "github.com/siemens/wfx/internal/workflow" "github.com/siemens/wfx/middleware/logging" "github.com/siemens/wfx/persistence" @@ -75,6 +78,17 @@ func Update(ctx context.Context, storage persistence.Storage, jobID string, newS return nil, fault.Wrap(err) } + _ = events.PublishEvent(ctx, &events.JobEvent{ + Ctime: strfmt.DateTime(time.Now()), + Action: events.ActionUpdateStatus, + Job: &model.Job{ + ID: result.ID, + ClientID: result.ClientID, + Workflow: &model.Workflow{Name: job.Workflow.Name}, + Status: result.Status, + }, + }) + contextLogger.Info(). Str("from", from). Str("to", newStatus.State). diff --git a/internal/handler/job/status/update_test.go b/internal/handler/job/status/update_test.go index c7bf2176..9059dff0 100644 --- a/internal/handler/job/status/update_test.go +++ b/internal/handler/job/status/update_test.go @@ -17,6 +17,7 @@ import ( "github.com/stretchr/testify/require" "github.com/siemens/wfx/generated/model" + "github.com/siemens/wfx/internal/handler/job/events" "github.com/siemens/wfx/persistence" "github.com/siemens/wfx/workflow/dau" ) @@ -40,23 +41,23 @@ func TestUpdateJob_Ok(t *testing.T) { var jobID string { - job, err2 := db.CreateJob(context.Background(), &model.Job{ + job, err := db.CreateJob(context.Background(), &model.Job{ ClientID: "abc", Workflow: wf, Status: &model.JobStatus{ClientID: "abc", State: tc.from}, }) jobID = job.ID - assert.NoError(t, err2) + assert.NoError(t, err) assert.Equal(t, tc.from, job.Status.State) } status, err := Update(context.Background(), db, jobID, &model.JobStatus{ - ClientID: "klaus", + ClientID: "foo", State: tc.to, Progress: 100, }, tc.eligible) assert.NoError(t, err) - assert.Equal(t, "klaus", status.ClientID) + assert.Equal(t, "foo", status.ClientID) assert.Equal(t, tc.expected, status.State) assert.Equal(t, int32(100), status.Progress) }) @@ -76,7 +77,7 @@ func TestUpdateJobStatus_Message(t *testing.T) { message := "Updating message!" - status, err := Update(context.Background(), db, job.ID, &model.JobStatus{ClientID: "klaus", Message: message, State: job.Status.State}, model.EligibleEnumCLIENT) + status, err := Update(context.Background(), db, job.ID, &model.JobStatus{ClientID: "foo", Message: message, State: job.Status.State}, model.EligibleEnumCLIENT) assert.NoError(t, err) assert.Equal(t, "INSTALLING", status.State) assert.Equal(t, int32(0), status.Progress) @@ -93,14 +94,14 @@ func TestUpdateJobStatus_StateWarp(t *testing.T) { wf := createDirectWorkflow(t, db) job, err := db.CreateJob(context.Background(), &model.Job{ - ClientID: "klaus", + ClientID: "foo", Workflow: wf, - Status: &model.JobStatus{ClientID: "klaus", State: from, DefinitionHash: "abc"}, + Status: &model.JobStatus{ClientID: "foo", State: from, DefinitionHash: "abc"}, }) require.NoError(t, err) updatedJob, err := db.UpdateJob(context.Background(), job, persistence.JobUpdate{Status: &model.JobStatus{ - ClientID: "klaus", + ClientID: "foo", State: "INSTALLING", DefinitionHash: job.Status.DefinitionHash, }}) @@ -128,13 +129,13 @@ func TestUpdateJobStatusNotAllowed(t *testing.T) { wf := createDirectWorkflow(t, db) var jobID string { - job, err2 := db.CreateJob(context.Background(), &model.Job{ + job, err := db.CreateJob(context.Background(), &model.Job{ ClientID: "abc", Workflow: wf, Status: &model.JobStatus{State: from}, }) jobID = job.ID - require.NoError(t, err2) + require.NoError(t, err) require.Equal(t, from, job.Status.State) } @@ -143,6 +144,37 @@ func TestUpdateJobStatusNotAllowed(t *testing.T) { assert.Nil(t, status) } +func TestUpdateJob_NotifySubscribers(t *testing.T) { + db := newInMemoryDB(t) + wf := createDirectWorkflow(t, db) + job, err := db.CreateJob(context.Background(), &model.Job{ + ClientID: "abc", + Workflow: wf, + Status: &model.JobStatus{ClientID: "abc", State: "ACTIVATING"}, + }) + require.NoError(t, err) + assert.Equal(t, "ACTIVATING", job.Status.State) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + ch, err := events.AddSubscriber(ctx, events.FilterParams{JobIDs: []string{job.ID}}, nil) + require.NoError(t, err) + + _, err = Update(context.Background(), db, job.ID, &model.JobStatus{ + ClientID: "foo", + State: "ACTIVATED", + Progress: 100, + }, model.EligibleEnumCLIENT) + require.NoError(t, err) + + event := <-ch + receivedEvent := event.Args[0].(*events.JobEvent) + assert.Equal(t, events.ActionUpdateStatus, receivedEvent.Action) + assert.Equal(t, "ACTIVATED", receivedEvent.Job.Status.State) + assert.Equal(t, wf.Name, receivedEvent.Job.Workflow.Name) +} + func createDirectWorkflow(t *testing.T, db persistence.Storage) *model.Workflow { wf, err := db.CreateWorkflow(context.Background(), dau.DirectWorkflow()) require.NoError(t, err) diff --git a/internal/handler/job/tags/add.go b/internal/handler/job/tags/add.go index ca10429f..8b5178ad 100644 --- a/internal/handler/job/tags/add.go +++ b/internal/handler/job/tags/add.go @@ -10,8 +10,12 @@ package tags import ( "context" + "time" "github.com/Southclaws/fault" + "github.com/go-openapi/strfmt" + "github.com/siemens/wfx/generated/model" + "github.com/siemens/wfx/internal/handler/job/events" "github.com/siemens/wfx/middleware/logging" "github.com/siemens/wfx/persistence" ) @@ -32,6 +36,17 @@ func Add(ctx context.Context, storage persistence.Storage, jobID string, tags [] return nil, fault.Wrap(err) } + _ = events.PublishEvent(ctx, &events.JobEvent{ + Ctime: strfmt.DateTime(time.Now()), + Action: events.ActionAddTags, + Job: &model.Job{ + ID: updatedJob.ID, + ClientID: updatedJob.ClientID, + Workflow: updatedJob.Workflow, + Tags: updatedJob.Tags, + }, + }) + contextLogger.Info().Msg("Added job tags") return updatedJob.Tags, nil } diff --git a/internal/handler/job/tags/add_test.go b/internal/handler/job/tags/add_test.go index 70ac2f7f..89dffa7b 100644 --- a/internal/handler/job/tags/add_test.go +++ b/internal/handler/job/tags/add_test.go @@ -11,9 +11,11 @@ package tags import ( "context" "errors" + "sort" "testing" "github.com/siemens/wfx/generated/model" + "github.com/siemens/wfx/internal/handler/job/events" "github.com/siemens/wfx/internal/persistence/entgo" "github.com/siemens/wfx/persistence" "github.com/siemens/wfx/workflow/dau" @@ -27,16 +29,27 @@ func TestAdd(t *testing.T) { wf, err := db.CreateWorkflow(context.Background(), dau.PhasedWorkflow()) require.NoError(t, err) job, err := db.CreateJob(context.Background(), &model.Job{ - ClientID: "klaus", + ClientID: "foo", Workflow: wf, Status: &model.JobStatus{State: "CREATED"}, }) require.NoError(t, err) - actual, err := Add(context.Background(), db, job.ID, []string{"foo", "bar"}) + ch, err := events.AddSubscriber(context.Background(), events.FilterParams{}, nil) require.NoError(t, err) - assert.Equal(t, []string{"bar", "foo"}, actual) + tags := []string{"foo", "bar"} + actual, err := Add(context.Background(), db, job.ID, tags) + require.NoError(t, err) + sort.Strings(tags) + + assert.Equal(t, tags, actual) + + ev := <-ch + jobEvent := ev.Args[0].(*events.JobEvent) + assert.Equal(t, events.ActionAddTags, jobEvent.Action) + assert.Equal(t, job.ID, jobEvent.Job.ID) + assert.Equal(t, tags, jobEvent.Job.Tags) } func TestAdd_FaultyStorageGet(t *testing.T) { diff --git a/internal/handler/job/tags/delete.go b/internal/handler/job/tags/delete.go index 5541bedd..95e8453d 100644 --- a/internal/handler/job/tags/delete.go +++ b/internal/handler/job/tags/delete.go @@ -10,8 +10,12 @@ package tags import ( "context" + "time" "github.com/Southclaws/fault" + "github.com/go-openapi/strfmt" + "github.com/siemens/wfx/generated/model" + "github.com/siemens/wfx/internal/handler/job/events" "github.com/siemens/wfx/middleware/logging" "github.com/siemens/wfx/persistence" ) @@ -31,6 +35,18 @@ func Delete(ctx context.Context, storage persistence.Storage, jobID string, tags contextLogger.Err(err).Msg("Failed to delete tags to job") return nil, fault.Wrap(err) } + + _ = events.PublishEvent(ctx, &events.JobEvent{ + Ctime: strfmt.DateTime(time.Now()), + Action: events.ActionDeleteTags, + Job: &model.Job{ + ID: updatedJob.ID, + ClientID: updatedJob.ClientID, + Workflow: updatedJob.Workflow, + Tags: updatedJob.Tags, + }, + }) + contextLogger.Info().Msg("Deleted job tags") return updatedJob.Tags, nil } diff --git a/internal/handler/job/tags/delete_test.go b/internal/handler/job/tags/delete_test.go index 087c0b22..1be760a5 100644 --- a/internal/handler/job/tags/delete_test.go +++ b/internal/handler/job/tags/delete_test.go @@ -14,6 +14,7 @@ import ( "testing" "github.com/siemens/wfx/generated/model" + "github.com/siemens/wfx/internal/handler/job/events" "github.com/siemens/wfx/persistence" "github.com/siemens/wfx/workflow/dau" "github.com/stretchr/testify/assert" @@ -26,16 +27,26 @@ func TestDelete(t *testing.T) { wf, err := db.CreateWorkflow(context.Background(), dau.DirectWorkflow()) require.NoError(t, err) job, err := db.CreateJob(context.Background(), &model.Job{ - ClientID: "klaus", + ClientID: "foo", Workflow: wf, Status: &model.JobStatus{State: "INSTALL"}, Tags: []string{"foo", "bar"}, }) require.NoError(t, err) + ch, err := events.AddSubscriber(context.Background(), events.FilterParams{}, nil) + require.NoError(t, err) + tags, err := Delete(context.Background(), db, job.ID, []string{"foo"}) require.NoError(t, err) - assert.Equal(t, []string{"bar"}, tags) + expectedTags := []string{"bar"} + assert.Equal(t, expectedTags, tags) + + ev := <-ch + jobEvent := ev.Args[0].(*events.JobEvent) + assert.Equal(t, events.ActionDeleteTags, jobEvent.Action) + assert.Equal(t, job.ID, jobEvent.Job.ID) + assert.Equal(t, expectedTags, jobEvent.Job.Tags) } func TestDelete_FaultyStorageGet(t *testing.T) { diff --git a/internal/handler/job/tags/get_test.go b/internal/handler/job/tags/get_test.go index f4508f31..3f18f230 100644 --- a/internal/handler/job/tags/get_test.go +++ b/internal/handler/job/tags/get_test.go @@ -24,7 +24,7 @@ func TestGet(t *testing.T) { wf, err := db.CreateWorkflow(context.Background(), dau.DirectWorkflow()) require.NoError(t, err) job, err := db.CreateJob(context.Background(), &model.Job{ - ClientID: "klaus", + ClientID: "foo", Workflow: wf, Status: &model.JobStatus{State: "CREATED"}, Tags: []string{"foo", "bar"}, @@ -42,7 +42,7 @@ func TestGetEmpty(t *testing.T) { wf, err := db.CreateWorkflow(context.Background(), dau.DirectWorkflow()) require.NoError(t, err) job, err := db.CreateJob(context.Background(), &model.Job{ - ClientID: "klaus", + ClientID: "foo", Workflow: wf, Status: &model.JobStatus{State: "CREATED"}, }) diff --git a/internal/persistence/entgo/job_create.go b/internal/persistence/entgo/job_create.go index 2724ee76..f6f7a15a 100644 --- a/internal/persistence/entgo/job_create.go +++ b/internal/persistence/entgo/job_create.go @@ -121,12 +121,11 @@ func createJobHelper(ctx context.Context, tx *ent.Tx, job *model.Job) (*model.Jo AddTagIDs(allTagIDs...). SetGroup(group) - var initialTime time.Time - if t := time.Time(job.Stime); t != initialTime { - builder.SetStime(t) + if job.Stime != nil { + builder.SetStime(time.Time(*job.Stime)) } - if t := time.Time(job.Mtime); t != initialTime { - builder.SetMtime(t) + if job.Mtime != nil { + builder.SetMtime(time.Time(*job.Mtime)) } entity, err := builder.Save(ctx) diff --git a/internal/persistence/entgo/job_get.go b/internal/persistence/entgo/job_get.go index b68e112e..30253e4d 100644 --- a/internal/persistence/entgo/job_get.go +++ b/internal/persistence/entgo/job_get.go @@ -64,12 +64,14 @@ func convertJob(entity *ent.Job) *model.Job { wf = convertWorkflow(entity.Edges.Workflow) } + stime := strfmt.DateTime(entity.Stime) + mtime := strfmt.DateTime(entity.Mtime) job := model.Job{ ID: entity.ID, ClientID: entity.ClientID, Definition: entity.Definition, - Stime: strfmt.DateTime(entity.Stime), - Mtime: strfmt.DateTime(entity.Mtime), + Stime: &stime, + Mtime: &mtime, Status: &entity.Status, Tags: convertTags(entity.Edges.Tags), Workflow: wf, diff --git a/internal/persistence/entgo/job_update.go b/internal/persistence/entgo/job_update.go index f6645889..117d0348 100644 --- a/internal/persistence/entgo/job_update.go +++ b/internal/persistence/entgo/job_update.go @@ -60,7 +60,7 @@ func doUpdateJob(ctx context.Context, tx *ent.Tx, job *model.Job, request persis updater := tx.Job.UpdateOneID(job.ID) - oldMtime := time.Time(job.Mtime) + oldMtime := time.Time(*job.Mtime) if request.Status != nil { updater.SetStatus(*request.Status) diff --git a/internal/persistence/tests/job_get.go b/internal/persistence/tests/job_get.go index c7a953b1..881727b3 100644 --- a/internal/persistence/tests/job_get.go +++ b/internal/persistence/tests/job_get.go @@ -80,11 +80,11 @@ func TestGetJobWithHistory(t *testing.T, db persistence.Storage) { func newValidJob(clientID string) *model.Job { now := strfmt.DateTime(time.Now()) return &model.Job{ - Mtime: now, - Stime: now, + Mtime: &now, + Stime: &now, ClientID: clientID, Status: &model.JobStatus{ - ClientID: "klaus", + ClientID: "foo", State: "CREATED", }, Tags: []string{ diff --git a/internal/persistence/tests/job_query.go b/internal/persistence/tests/job_query.go index a1e1883c..c455a930 100644 --- a/internal/persistence/tests/job_query.go +++ b/internal/persistence/tests/job_query.go @@ -55,22 +55,23 @@ func TestQueryJobsFilter(t *testing.T, db persistence.Storage) { Tags: []string{"bar", "foo"}, }) require.NoError(t, err) - var initialTime time.Time - assert.NotEqual(t, initialTime, firstJob.Stime) - assert.NotEqual(t, initialTime, firstJob.Mtime) - assert.True(t, time.Time(firstJob.Stime).After(now) || time.Time(firstJob.Stime).Equal(now)) - assert.True(t, time.Time(firstJob.Mtime).After(now) || time.Time(firstJob.Mtime).Equal(now)) + assert.NotNil(t, firstJob.Stime) + assert.NotNil(t, firstJob.Mtime) + assert.True(t, time.Time(*firstJob.Stime).After(now) || time.Time(*firstJob.Stime).Equal(now)) + assert.True(t, time.Time(*firstJob.Mtime).After(now) || time.Time(*firstJob.Mtime).Equal(now)) + secondStime := strfmt.DateTime(now.Add(time.Second)) secondJob, err := db.CreateJob(context.Background(), &model.Job{ ClientID: clientID, Workflow: wf, Status: &model.JobStatus{ State: installState, }, - Stime: strfmt.DateTime(now.Add(time.Second)), + Stime: &secondStime, }) require.NoError(t, err) + thirdStime := strfmt.DateTime(now.Add(2 * time.Second)) thirdJob, err := db.CreateJob(context.Background(), &model.Job{ ClientID: clientID, Workflow: wf, @@ -78,7 +79,7 @@ func TestQueryJobsFilter(t *testing.T, db persistence.Storage) { State: activatedState, }, Tags: []string{"meh"}, - Stime: strfmt.DateTime(now.Add(2 * time.Second)), + Stime: &thirdStime, }) require.NoError(t, err) @@ -177,14 +178,16 @@ func TestGetJobsSorted(t *testing.T, db persistence.Storage) { _, err := db.CreateWorkflow(context.Background(), tmp.Workflow) require.NoError(t, err) - tmp.Stime = strfmt.DateTime(time.Now().Add(-2 * time.Minute)) + stime := strfmt.DateTime(time.Now().Add(-2 * time.Minute)) + tmp.Stime = &stime first, err = db.CreateJob(context.Background(), tmp) require.NoError(t, err) } { tmp := newValidJob(clientID) - tmp.Mtime = strfmt.DateTime(time.Now().Add(-time.Minute)) + mtime := strfmt.DateTime(time.Now().Add(-time.Minute)) + tmp.Mtime = &mtime second, err = db.CreateJob(context.Background(), tmp) require.NoError(t, err) } @@ -247,7 +250,7 @@ func TestGetJobMaxHistorySize(t *testing.T, db persistence.Storage) { { // job which we are going to update often - tmp := newValidJob("klaus") + tmp := newValidJob("foo") tmp.Status.Message = "0" _, err := db.CreateWorkflow(context.Background(), tmp.Workflow) require.NoError(t, err) diff --git a/internal/persistence/tests/job_update_definition.go b/internal/persistence/tests/job_update_definition.go index befe1fbc..d53a3b55 100644 --- a/internal/persistence/tests/job_update_definition.go +++ b/internal/persistence/tests/job_update_definition.go @@ -45,7 +45,7 @@ func TestUpdateJobDefinition(t *testing.T, db persistence.Storage) { time.Sleep(10 * time.Millisecond) updatedJob, err := db.UpdateJob(context.Background(), job, persistence.JobUpdate{Definition: &newDefinition}) assert.NoError(t, err) - assert.Greater(t, updatedJob.Mtime, mtime) + assert.Greater(t, *updatedJob.Mtime, *mtime) assert.Equal(t, "http://localhost/new_file.tgz", updatedJob.Definition["url"]) assert.Empty(t, updatedJob.Definition["sha256"]) } diff --git a/internal/persistence/tests/job_update_status.go b/internal/persistence/tests/job_update_status.go index da04fd10..d84e23e1 100644 --- a/internal/persistence/tests/job_update_status.go +++ b/internal/persistence/tests/job_update_status.go @@ -36,7 +36,7 @@ func TestUpdateJobStatus(t *testing.T, db persistence.Storage) { update := model.JobStatus{Message: message, State: "ACTIVATING"} updatedJob, err := db.UpdateJob(context.Background(), job, persistence.JobUpdate{Status: &update}) assert.NoError(t, err) - assert.Greater(t, updatedJob.Mtime, mtime) + assert.Greater(t, *updatedJob.Mtime, *mtime) assert.Equal(t, "ACTIVATING", updatedJob.Status.State) assert.Equal(t, message, updatedJob.Status.Message) assert.Len(t, updatedJob.History, 0) @@ -45,7 +45,7 @@ func TestUpdateJobStatus(t *testing.T, db persistence.Storage) { job, err := db.GetJob(context.Background(), job.ID, persistence.FetchParams{History: true}) require.NoError(t, err) assert.Len(t, job.History, 1) - assert.Equal(t, job.Stime, job.History[0].Mtime) + assert.Equal(t, *job.Stime, job.History[0].Mtime) } } diff --git a/internal/producer/text_event_stream.go b/internal/producer/text_event_stream.go new file mode 100644 index 00000000..6323fac7 --- /dev/null +++ b/internal/producer/text_event_stream.go @@ -0,0 +1,37 @@ +package producer + +/* + * SPDX-FileCopyrightText: 2023 Siemens AG + * + * SPDX-License-Identifier: Apache-2.0 + * + * Author: Michael Adler + */ + +import ( + "encoding/json" + "io" + + "github.com/Southclaws/fault" + "github.com/go-openapi/runtime" +) + +func TextEventStreamProducer() runtime.Producer { + return runtime.ProducerFunc(func(rw io.Writer, data any) error { + if _, err := rw.Write([]byte("data: ")); err != nil { + return fault.Wrap(err) + } + b, err := json.Marshal(data) + if err != nil { + return fault.Wrap(err) + } + if _, err := rw.Write(b); err != nil { + return fault.Wrap(err) + } + // text/event-stream responses are "chunked" with double newline breaks + if _, err := rw.Write([]byte("\n\n")); err != nil { + return fault.Wrap(err) + } + return nil + }) +} diff --git a/internal/producer/text_event_stream_test.go b/internal/producer/text_event_stream_test.go new file mode 100644 index 00000000..3e356bcd --- /dev/null +++ b/internal/producer/text_event_stream_test.go @@ -0,0 +1,34 @@ +package producer + +/* + * SPDX-FileCopyrightText: 2023 Siemens AG + * + * SPDX-License-Identifier: Apache-2.0 + * + * Author: Michael Adler + */ + +import ( + "bytes" + "testing" + + "github.com/siemens/wfx/generated/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTextEventStreamProducer(t *testing.T) { + prod := TextEventStreamProducer() + event := model.JobStatus{ + ClientID: "foo", + Message: "hello world", + State: "INSTALLING", + } + + buf := new(bytes.Buffer) + err := prod.Produce(buf, event) + require.NoError(t, err) + assert.Equal(t, `data: {"clientId":"foo","message":"hello world","state":"INSTALLING"} + +`, buf.String()) +} diff --git a/internal/workflow/workflow.go b/internal/workflow/workflow.go index c6ab958b..ecfd585d 100644 --- a/internal/workflow/workflow.go +++ b/internal/workflow/workflow.go @@ -10,6 +10,7 @@ package workflow import "github.com/siemens/wfx/generated/model" +// FindStateGroup tries to find the group of a state. If not found, it returns the empty string. func FindStateGroup(workflow *model.Workflow, state string) string { for _, group := range workflow.Groups { for _, s := range group.States { diff --git a/internal/workflow/workflow_test.go b/internal/workflow/workflow_test.go index fb6c645c..c4d68048 100644 --- a/internal/workflow/workflow_test.go +++ b/internal/workflow/workflow_test.go @@ -18,8 +18,14 @@ import ( func TestFindStateGroup(t *testing.T) { workflow := dau.PhasedWorkflow() - group := FindStateGroup(workflow, "DOWNLOAD") - assert.Equal(t, "OPEN", group) + { + group := FindStateGroup(workflow, "DOWNLOAD") + assert.Equal(t, "OPEN", group) + } + { + group := FindStateGroup(workflow, "FOO") + assert.Equal(t, "", group) + } } func TestFollowTransitions(t *testing.T) { diff --git a/middleware/logging/writer.go b/middleware/logging/writer.go index b0ae1c82..cc9fed12 100644 --- a/middleware/logging/writer.go +++ b/middleware/logging/writer.go @@ -43,3 +43,10 @@ func (w *responseWriter) Write(b []byte) (int, error) { n, err := w.bodyWriter.Write(b) return n, fault.Wrap(err) } + +// Flush implements the http.Flusher interface. +// This is used by the server-sent events implementation to flush a single event to the client. +func (w *responseWriter) Flush() { + flusher := w.httpWriter.(http.Flusher) + flusher.Flush() +} diff --git a/middleware/logging/writer_test.go b/middleware/logging/writer_test.go index f08f6fb1..92cb9b0e 100644 --- a/middleware/logging/writer_test.go +++ b/middleware/logging/writer_test.go @@ -38,3 +38,11 @@ func TestWriter(t *testing.T) { assert.Equal(t, "hello world", string(body)) } + +func TestWriterImplementsFlusher(t *testing.T) { + recorder := httptest.NewRecorder() + var w http.ResponseWriter = newMyResponseWriter(recorder) + flusher, ok := w.(http.Flusher) + assert.True(t, ok) + flusher.Flush() +} diff --git a/middleware/responder/sse/main_test.go b/middleware/responder/sse/main_test.go new file mode 100644 index 00000000..2fabaa3f --- /dev/null +++ b/middleware/responder/sse/main_test.go @@ -0,0 +1,19 @@ +package sse + +/* + * SPDX-FileCopyrightText: 2023 Siemens AG + * + * SPDX-License-Identifier: Apache-2.0 + * + * Author: Michael Adler + */ + +import ( + "testing" + + "go.uber.org/goleak" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} diff --git a/middleware/responder/sse/responder.go b/middleware/responder/sse/responder.go new file mode 100644 index 00000000..a4570a4a --- /dev/null +++ b/middleware/responder/sse/responder.go @@ -0,0 +1,76 @@ +package sse + +/* + * SPDX-FileCopyrightText: 2023 Siemens AG + * + * SPDX-License-Identifier: Apache-2.0 + * + * Author: Michael Adler + */ + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/go-openapi/runtime" + "github.com/go-openapi/runtime/middleware" + "github.com/olebedev/emitter" + "github.com/siemens/wfx/middleware/logging" +) + +// Responder streams server-sent events (SSE) to a client. +// It listens for events from the provided channel and dispatches them +// to the client as soon as they arrive. +// +// Parameters: +// - ctx: The context for managing the lifecycle of the stream. If canceled, streaming stops. +// - source: A read-only channel of events to be transmitted. +func Responder(ctx context.Context, source <-chan emitter.Event) middleware.ResponderFunc { + return func(w http.ResponseWriter, p runtime.Producer) { + log := logging.LoggerFromCtx(ctx) + + flusher, _ := w.(http.Flusher) + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") + + flusher.Flush() + + var id uint64 = 1 + Loop: + for { + log.Debug().Msg("Waiting for next event") + select { + case ev, ok := <-source: + if !ok { + log.Debug().Msg("SSE channel is closed") + break Loop + } + b, err := json.Marshal(ev.Args[0]) + if err != nil { + log.Error().Err(err).Msg("Failed to marshal status event") + continue Loop + } + log.Debug().RawJSON("event", b).Msg("Sending event to client") + + _, _ = w.Write([]byte("data: ")) + _, _ = w.Write(b) + // must end with two newlines as required by the SSE spec: + _, _ = w.Write([]byte(fmt.Sprintf("\nid: %d\n\n", id))) + + flusher.Flush() + + id++ + case <-ctx.Done(): + // this typically happens when the client closes the connection + log.Debug().Msg("Context is done") + break Loop + } + } + log.Info().Msg("Event Subscriber finished") + } +} diff --git a/middleware/responder/sse/responder_test.go b/middleware/responder/sse/responder_test.go new file mode 100644 index 00000000..239f2562 --- /dev/null +++ b/middleware/responder/sse/responder_test.go @@ -0,0 +1,55 @@ +package sse + +/* + * SPDX-FileCopyrightText: 2023 Siemens AG + * + * SPDX-License-Identifier: Apache-2.0 + * + * Author: Michael Adler + */ + +import ( + "context" + "net/http/httptest" + "sync" + "testing" + + "github.com/olebedev/emitter" + "github.com/siemens/wfx/generated/model" + "github.com/siemens/wfx/internal/producer" + "github.com/stretchr/testify/assert" +) + +func TestSSEResponder(t *testing.T) { + events := make(chan emitter.Event) + + rw := httptest.NewRecorder() + var wg sync.WaitGroup + + wg.Add(1) + go func() { + defer wg.Done() + responder := Responder(context.Background(), events) + responder.WriteResponse(rw, producer.JSONProducer()) + }() + + jobStatus := model.JobStatus{ + ClientID: "foo", + Message: "hello world", + State: "INSTALLING", + } + events <- emitter.Event{ + Topic: "", + OriginalTopic: "", + Flags: 0, + Args: []any{jobStatus}, + } + close(events) + + wg.Wait() + + assert.Equal(t, `data: {"clientId":"foo","message":"hello world","state":"INSTALLING"} +id: 1 + +`, rw.Body.String()) +} diff --git a/middleware/responder/util/content_type.go b/middleware/responder/util/content_type.go new file mode 100644 index 00000000..c0d7743d --- /dev/null +++ b/middleware/responder/util/content_type.go @@ -0,0 +1,29 @@ +package util + +/* + * SPDX-FileCopyrightText: 2023 Siemens AG + * + * SPDX-License-Identifier: Apache-2.0 + * + * Author: Michael Adler + */ + +import ( + "net/http" + + "github.com/go-openapi/runtime" + "github.com/go-openapi/runtime/middleware" + "github.com/rs/zerolog/log" + "github.com/siemens/wfx/internal/producer" +) + +// ForceJSONResponse generates a JSON response using the provided payload. +func ForceJSONResponse(statusCode int, payload any) middleware.ResponderFunc { + return func(rw http.ResponseWriter, _ runtime.Producer) { + rw.Header().Add("Content-Type", "application/json") + rw.WriteHeader(statusCode) + if err := producer.JSONProducer().Produce(rw, payload); err != nil { + log.Error().Err(err).Msg("Failed to generate JSON response") + } + } +} diff --git a/middleware/responder/util/content_type_test.go b/middleware/responder/util/content_type_test.go new file mode 100644 index 00000000..948ec06d --- /dev/null +++ b/middleware/responder/util/content_type_test.go @@ -0,0 +1,33 @@ +package util + +/* + * SPDX-FileCopyrightText: 2023 Siemens AG + * + * SPDX-License-Identifier: Apache-2.0 + * + * Author: Michael Adler + */ + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/siemens/wfx/internal/producer" + "github.com/stretchr/testify/assert" +) + +func TestForceContentType(t *testing.T) { + resp := map[string]string{"hello": "world"} + f := ForceJSONResponse(http.StatusNotFound, resp) + rec := httptest.NewRecorder() + f.WriteResponse(rec, producer.JSONProducer()) + + result := rec.Result() + assert.Equal(t, "application/json", result.Header.Get("Content-Type")) + assert.Equal(t, http.StatusNotFound, result.StatusCode) + b, _ := io.ReadAll(result.Body) + body := string(b) + assert.JSONEq(t, `{"hello":"world"}`, body) +} diff --git a/middleware/responder/util/main_test.go b/middleware/responder/util/main_test.go new file mode 100644 index 00000000..92b2c098 --- /dev/null +++ b/middleware/responder/util/main_test.go @@ -0,0 +1,19 @@ +package util + +/* + * SPDX-FileCopyrightText: 2023 Siemens AG + * + * SPDX-License-Identifier: Apache-2.0 + * + * Author: Michael Adler + */ + +import ( + "testing" + + "go.uber.org/goleak" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} diff --git a/persistence/all_test.go b/persistence/all_test.go index 87b86981..fc017afd 100644 --- a/persistence/all_test.go +++ b/persistence/all_test.go @@ -9,69 +9,19 @@ package persistence */ import ( - "context" "testing" - "github.com/siemens/wfx/generated/model" "github.com/stretchr/testify/assert" ) -type testStorage struct{} - -func (teststorage *testStorage) Initialize(context.Context, string) error { - panic("not implemented") -} - -func (teststorage *testStorage) Shutdown() { - panic("not implemented") -} - -func (teststorage *testStorage) CheckHealth(context.Context) error { - panic("not implemented") -} - -func (teststorage *testStorage) CreateJob(context.Context, *model.Job) (*model.Job, error) { - panic("not implemented") -} - -func (teststorage *testStorage) GetJob(context.Context, string, FetchParams) (*model.Job, error) { - panic("not implemented") -} - -func (teststorage *testStorage) UpdateJob(context.Context, *model.Job, JobUpdate) (*model.Job, error) { - panic("not implemented") -} - -func (teststorage *testStorage) DeleteJob(context.Context, string) error { - panic("not implemented") -} - -func (teststorage *testStorage) QueryJobs(context.Context, FilterParams, SortParams, PaginationParams) (*model.PaginatedJobList, error) { - panic("not implemented") -} - -func (teststorage *testStorage) CreateWorkflow(context.Context, *model.Workflow) (*model.Workflow, error) { - panic("not implemented") -} - -func (teststorage *testStorage) GetWorkflow(context.Context, string) (*model.Workflow, error) { - panic("not implemented") -} - -func (teststorage *testStorage) DeleteWorkflow(context.Context, string) error { - panic("not implemented") -} - -func (teststorage *testStorage) QueryWorkflows(context.Context, PaginationParams) (*model.PaginatedWorkflowList, error) { - panic("not implemented") -} - func TestStorageAPI(t *testing.T) { - storage1 := &testStorage{} + storage1 := NewMockStorage(t) + RegisterStorage("storage1", storage1) actual := GetStorage("storage1") assert.Same(t, storage1, actual) - storage2 := &testStorage{} + + storage2 := NewMockStorage(t) RegisterStorage("storage2", storage2) all := Storages() assert.Len(t, all, 2) diff --git a/spec/wfx.swagger.yml b/spec/wfx.swagger.yml index 7833c331..cdf00def 100644 --- a/spec/wfx.swagger.yml +++ b/spec/wfx.swagger.yml @@ -68,8 +68,6 @@ definitions: type: object required: - name - - states - - transitions properties: name: type: string @@ -86,18 +84,19 @@ definitions: example: This is a workflow states: type: array - minItems: 1 + x-omitempty: true <<: *maxStateCount items: $ref: "#/definitions/State" groups: type: array + x-omitempty: true <<: *maxGroupCount items: $ref: "#/definitions/Group" transitions: type: array - minItems: 1 + x-omitempty: true <<: *maxTransitionCount items: $ref: "#/definitions/Transition" @@ -266,6 +265,7 @@ definitions: $ref: "#/definitions/Workflow" tags: type: array + x-omitempty: true items: type: string <<: *maxTagsCount @@ -278,18 +278,21 @@ definitions: status: $ref: "#/definitions/JobStatus" stime: - description: Date and time (ISO8601) when the job was created (set by wfx) + description: Date and time (ISO8601) when the job was created (set by wfx). Although stime conceptually always exists, it's nullable because we don't want to serialize stime in some cases (e.g. for job events). type: string format: date-time readOnly: true + x-nullable: true mtime: description: Date and time (ISO8601) when the job was last modified (set by wfx) type: string format: date-time readOnly: true + x-nullable: true history: description: The job's history. Last in, first out (LIFO). Array is truncated if its length exceeds the maximum allowed length. type: array + x-omitempty: true <<: *maxHistoryCount items: $ref: "#/definitions/History" @@ -427,6 +430,11 @@ x-paths-templates: logref: 11cc67762090e15b79a1387eca65ba65 message: Job ID was not found + jobTerminalStateError: &jobTerminalStateError + code: wfx.jobTerminalState + logref: 916f0a913a3e4a52a96bd271e029c201 + message: The request was invalid because the job is in a terminal state + workflowNotFoundError: &workflowNotFoundError code: wfx.workflowNotFound logref: c452719774086b6e803bb8f6ecea9899 @@ -656,6 +664,67 @@ paths: errors: - <<: *invalidRequestError + /jobs/events: + get: + summary: Subscribe to job-related events such as status updates + description: > + Obtain instant notifications when there are job changes matching the criteria. + This endpoint utilizes server-sent events (SSE), where responses are "chunked" with double newline breaks. + For example, a single event might look like this: + data: {"clientId":"example_client","state":"INSTALLING"}\n\n + tags: + - jobs + - northbound + - southbound + produces: + - application/json + - text/event-stream + parameters: + - name: clientIds + in: query + description: The job's clientId must be one of these clientIds (comma-separated). + required: false + type: string + - name: jobIds + in: query + description: The job's id must be one of these ids (comma-separated). + required: false + type: string + - name: workflows + in: query + description: The job's workflow must be equal to one of the provided workflow names (comma-separated). + required: false + type: string + - name: tags + in: query + description: > + A (comma-separated) list of tags to include into each job event. + This can be used to aggregrate events from multiple wfx instances. + required: false + type: string + + responses: + "default": + description: Other error with any status code and response body format + "200": + description: A stream of server-sent events + "400": + description: Bad Request + schema: + $ref: "#/definitions/ErrorResponse" + examples: + Error responses occurring at this operation for invalid requests: + errors: + - <<: *jobTerminalStateError + "404": + description: Not Found + schema: + $ref: "#/definitions/ErrorResponse" + examples: + Error responses occurring at this operation while updating a non-existent job: + errors: + - <<: *jobNotFoundError + /jobs/{id}: get: summary: Job description for a given ID diff --git a/test/05-workflows.bats b/test/05-workflows.bats index f0cf9555..553bb4dd 100755 --- a/test/05-workflows.bats +++ b/test/05-workflows.bats @@ -90,3 +90,30 @@ teardown_file() { jobs_open_closed=$(wfxctl job query --group OPEN --group CLOSED --filter ".content | length") assert_equal "$jobs_open_closed" 2 } + +@test "Subscribe to job events" { + cd "$BATS_TEST_TMPDIR" + ID=$(echo '{ "title": "Expose Job API" }' | + wfxctl job create --workflow wfx.workflow.kanban \ + --client-id Dana \ + --filter='.id' --raw - 2>/dev/null) + + curl -s --no-buffer "localhost:8080/api/wfx/v1/jobs/events?jobIds=$ID&tags=bats" > curl.out & + sleep 1 + for state in PROGRESS VALIDATE DONE; do + wfxctl job update-status \ + --actor=client \ + --id "$ID" \ + --state "$state" 1>/dev/null 2>&1 + done + for i in {1..30}; do + if grep -q DONE curl.out; then + break + fi + sleep 1 + done + + assert_file_contains curl.out '"state":"PROGRESS"' + assert_file_contains curl.out '"state":"VALIDATE"' + assert_file_contains curl.out '"state":"DONE"' +}