Skip to content

Commit 8bfe293

Browse files
committed
refactor(rest-api): address review feedback on Task model
Three follow-ups from thossain-nv on PR #2288: - Inline BuildAPITaskOptions as APIGetTasksRequest.TaskOptions, since the function only ever consumed a single request and was already conceptually a getter on it. - Move parseTaskReportV1 into APITaskReportV1.UnmarshalJSON so the custom Flow snake_case decoder is reachable via the standard encoding/json entry point. The marshal path stays camelCase via struct tags; the doc comment flags the resulting asymmetry. - Rename WithReport to WithTaskReport so future model option constructors for other API types (machine, rack, ...) can coexist in this shared package without colliding on a generic "Report" name. Signed-off-by: Kun Zhao <kunzhao@nvidia.com>
1 parent 35dab00 commit 8bfe293

3 files changed

Lines changed: 60 additions & 51 deletions

File tree

rest-api/api/pkg/api/handler/task.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ func (gth GetTaskHandler) Handle(c echo.Context) error {
183183
return cutil.NewAPIErrorResponse(c, http.StatusNotFound, "Task not found", nil)
184184
}
185185

186-
apiTask := model.NewAPITask(tasks[0], model.WithReport())
186+
apiTask := model.NewAPITask(tasks[0], model.WithTaskReport())
187187

188188
logger.Info().Msg("finishing API handler")
189189

@@ -354,7 +354,7 @@ func (cth CancelTaskHandler) Handle(c echo.Context) error {
354354
return cutil.NewAPIErrorResponse(c, code, fmt.Sprintf("Failed to execute Task cancellation workflow on Site: %s", unwrapErr), nil)
355355
}
356356

357-
apiTask := model.NewAPITask(flowResponse.GetTask(), model.WithReport())
357+
apiTask := model.NewAPITask(flowResponse.GetTask(), model.WithTaskReport())
358358

359359
logger.Info().Msg("finishing API handler")
360360
return c.JSON(http.StatusAccepted, apiTask)
@@ -529,7 +529,7 @@ func (h GetRackTasksHandler) Handle(c echo.Context) error {
529529
return cutil.NewAPIErrorResponse(c, code, fmt.Sprintf("Failed to execute workflow to retrieve all Rack Tasks: %s", unwrapErr), nil)
530530
}
531531

532-
taskOpts := model.BuildAPITaskOptions(apiRequest)
532+
taskOpts := apiRequest.TaskOptions()
533533
apiTasks := make([]*model.APITask, 0, len(flowResponse.GetTasks()))
534534
for _, t := range flowResponse.GetTasks() {
535535
apiTasks = append(apiTasks, model.NewAPITask(t, taskOpts...))
@@ -715,7 +715,7 @@ func (h GetTrayTasksHandler) Handle(c echo.Context) error {
715715
return cutil.NewAPIErrorResponse(c, code, fmt.Sprintf("Failed to execute workflow to retrieve all Tray Tasks: %s", unwrapErr), nil)
716716
}
717717

718-
taskOpts := model.BuildAPITaskOptions(apiRequest)
718+
taskOpts := apiRequest.TaskOptions()
719719
apiTasks := make([]*model.APITask, 0, len(flowResponse.GetTasks()))
720720
for _, t := range flowResponse.GetTasks() {
721721
apiTasks = append(apiTasks, model.NewAPITask(t, taskOpts...))

rest-api/api/pkg/api/model/task.go

Lines changed: 42 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -132,27 +132,26 @@ type flowTaskReportV1Step struct {
132132
Error string `json:"error,omitempty"`
133133
}
134134

135-
// parseTaskReportV1 decodes the JSON Flow writes into Task.report and
136-
// translates it into the REST-facing APITaskReportV1 (snake_case ->
137-
// camelCase). Returns nil for empty input. Returns nil if the payload
138-
// fails to parse or carries a version other than 1: omitting the field
139-
// is preferable to surfacing a stale/wrong shape behind a v1 contract.
140-
func parseTaskReportV1(raw string) *APITaskReportV1 {
141-
if raw == "" {
142-
return nil
143-
}
135+
// UnmarshalJSON decodes Flow's snake_case wire format for Task.report
136+
// into the REST-facing APITaskReportV1. The marshal path is asymmetric:
137+
// json.Marshal walks the struct tags and emits camelCase per the REST
138+
// surface, while UnmarshalJSON intentionally only accepts Flow's
139+
// snake_case input. Round-tripping a marshalled APITaskReportV1 through
140+
// json.Unmarshal will therefore drop most fields; callers that need a
141+
// round-trippable form should decode into a map. Returns an error for
142+
// any payload that fails to parse or carries a version other than 1, so
143+
// FromProto can drop the field rather than surface an off-contract shape.
144+
func (r *APITaskReportV1) UnmarshalJSON(data []byte) error {
144145
var src flowTaskReportV1
145-
if err := json.Unmarshal([]byte(raw), &src); err != nil {
146-
return nil
146+
if err := json.Unmarshal(data, &src); err != nil {
147+
return err
147148
}
148149
if src.Version != 1 {
149-
return nil
150-
}
151-
out := &APITaskReportV1{
152-
Version: src.Version,
153-
Error: src.Error,
154-
Stages: make([]APITaskReportV1Stage, 0, len(src.Stages)),
150+
return fmt.Errorf("unsupported task report version %d", src.Version)
155151
}
152+
r.Version = src.Version
153+
r.Error = src.Error
154+
r.Stages = make([]APITaskReportV1Stage, 0, len(src.Stages))
156155
for _, s := range src.Stages {
157156
dstStage := APITaskReportV1Stage{
158157
Number: s.Number,
@@ -174,9 +173,9 @@ func parseTaskReportV1(raw string) *APITaskReportV1 {
174173
Error: p.Error,
175174
})
176175
}
177-
out.Stages = append(out.Stages, dstStage)
176+
r.Stages = append(r.Stages, dstStage)
178177
}
179-
return out
178+
return nil
180179
}
181180

182181
// APITaskOption configures optional fields populated on an APITask.
@@ -188,26 +187,17 @@ type apiTaskOptions struct {
188187
withReport bool
189188
}
190189

191-
// WithReport populates APITask.Report by decoding Task.report as
190+
// WithTaskReport populates APITask.Report by decoding Task.report as
192191
// APITaskReportV1. Without this option, Report is left nil and is
193192
// omitted from the JSON response. A malformed or non-v1 payload also
194193
// yields nil so the response never carries an off-contract shape.
195-
func WithReport() APITaskOption {
194+
// Named WithTaskReport rather than WithReport so future option
195+
// constructors for other API types (machine, rack, ...) can coexist
196+
// in this package without a naming collision.
197+
func WithTaskReport() APITaskOption {
196198
return func(o *apiTaskOptions) { o.withReport = true }
197199
}
198200

199-
// BuildAPITaskOptions translates the opt-in toggles on an
200-
// APIGetTasksRequest into the APITaskOption slice the list-task
201-
// handlers pass to NewAPITask. Centralized so new opt-in fields
202-
// only need wiring in one place rather than in each list handler.
203-
func BuildAPITaskOptions(req APIGetTasksRequest) []APITaskOption {
204-
var opts []APITaskOption
205-
if req.IncludeReport {
206-
opts = append(opts, WithReport())
207-
}
208-
return opts
209-
}
210-
211201
func (t *APITask) FromProto(task *flowv1.Task, opts ...APITaskOption) {
212202
if task == nil {
213203
return
@@ -233,7 +223,12 @@ func (t *APITask) FromProto(task *flowv1.Task, opts ...APITaskOption) {
233223
t.Created = task.GetCreatedAt().AsTime().UTC()
234224
t.Updated = task.GetUpdatedAt().AsTime().UTC()
235225
if o.withReport {
236-
t.Report = parseTaskReportV1(task.GetReport())
226+
if raw := task.GetReport(); raw != "" {
227+
var r APITaskReportV1
228+
if err := json.Unmarshal([]byte(raw), &r); err == nil {
229+
t.Report = &r
230+
}
231+
}
237232
}
238233
}
239234

@@ -283,6 +278,19 @@ func (r *APIGetTasksRequest) Validate() error {
283278
return nil
284279
}
285280

281+
// TaskOptions returns the APITaskOption slice the list-task handlers
282+
// pass to NewAPITask, translating the opt-in toggles on this request
283+
// (IncludeReport today, more later) into the corresponding option
284+
// constructors. Centralized here so new opt-in fields wire up in one
285+
// place rather than in each list handler.
286+
func (r *APIGetTasksRequest) TaskOptions() []APITaskOption {
287+
var opts []APITaskOption
288+
if r.IncludeReport {
289+
opts = append(opts, WithTaskReport())
290+
}
291+
return opts
292+
}
293+
286294
// QueryValues returns query parameters that participate in deterministic
287295
// workflow ID hashing, including pagination fields so concurrent requests
288296
// for different pages do not reuse the same workflow execution.

rest-api/api/pkg/api/model/task_test.go

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ func TestNewAPITask_Report(t *testing.T) {
187187
assert.Nil(t, result.Report, "Report must default to nil so the JSON field is omitted")
188188
})
189189

190-
t.Run("WithReport decodes a v1 payload into the typed struct with camelCase keys", func(t *testing.T) {
190+
t.Run("WithTaskReport decodes a v1 payload into the typed struct with camelCase keys", func(t *testing.T) {
191191
body := `{
192192
"version": 1,
193193
"stages": [
@@ -214,7 +214,7 @@ func TestNewAPITask_Report(t *testing.T) {
214214
Report: body,
215215
}
216216

217-
result := NewAPITask(task, WithReport())
217+
result := NewAPITask(task, WithTaskReport())
218218

219219
require.NotNil(t, result.Report)
220220
assert.Equal(t, 1, result.Report.Version)
@@ -239,50 +239,51 @@ func TestNewAPITask_Report(t *testing.T) {
239239
assert.NotContains(t, step, "component_type")
240240
})
241241

242-
t.Run("WithReport on empty proto report yields nil", func(t *testing.T) {
242+
t.Run("WithTaskReport on empty proto report yields nil", func(t *testing.T) {
243243
task := &flowv1.Task{
244244
Id: &flowv1.UUID{Id: "task-rep-3"},
245245
Status: flowv1.TaskStatus_TASK_STATUS_PENDING,
246246
}
247247

248-
result := NewAPITask(task, WithReport())
248+
result := NewAPITask(task, WithTaskReport())
249249

250250
assert.Nil(t, result.Report, "Empty proto report must not surface as an empty JSON value")
251251
})
252252

253-
t.Run("WithReport on malformed JSON yields nil", func(t *testing.T) {
253+
t.Run("WithTaskReport on malformed JSON yields nil", func(t *testing.T) {
254254
task := &flowv1.Task{
255255
Id: &flowv1.UUID{Id: "task-rep-4"},
256256
Status: flowv1.TaskStatus_TASK_STATUS_RUNNING,
257257
Report: `{`,
258258
}
259259

260-
result := NewAPITask(task, WithReport())
260+
result := NewAPITask(task, WithTaskReport())
261261

262262
assert.Nil(t, result.Report, "Malformed report must not surface as a partial struct")
263263
})
264264

265-
t.Run("WithReport on non-v1 payload yields nil", func(t *testing.T) {
265+
t.Run("WithTaskReport on non-v1 payload yields nil", func(t *testing.T) {
266266
task := &flowv1.Task{
267267
Id: &flowv1.UUID{Id: "task-rep-5"},
268268
Status: flowv1.TaskStatus_TASK_STATUS_RUNNING,
269269
Report: `{"version":2,"stages":[]}`,
270270
}
271271

272-
result := NewAPITask(task, WithReport())
272+
result := NewAPITask(task, WithTaskReport())
273273

274274
assert.Nil(t, result.Report, "v2+ payload must not be exposed behind the v1 contract")
275275
})
276276
}
277277

278-
func TestBuildAPITaskOptions(t *testing.T) {
278+
func TestAPIGetTasksRequest_TaskOptions(t *testing.T) {
279279
t.Run("default request yields no options", func(t *testing.T) {
280-
opts := BuildAPITaskOptions(APIGetTasksRequest{SiteID: "s"})
281-
assert.Empty(t, opts)
280+
req := APIGetTasksRequest{SiteID: "s"}
281+
assert.Empty(t, req.TaskOptions())
282282
})
283283

284-
t.Run("includeReport=true yields WithReport()", func(t *testing.T) {
285-
opts := BuildAPITaskOptions(APIGetTasksRequest{SiteID: "s", IncludeReport: true})
284+
t.Run("includeReport=true yields WithTaskReport()", func(t *testing.T) {
285+
req := APIGetTasksRequest{SiteID: "s", IncludeReport: true}
286+
opts := req.TaskOptions()
286287
require.Len(t, opts, 1)
287288

288289
// The option must decode Task.report when the proto report is non-empty.

0 commit comments

Comments
 (0)