Skip to content

Commit ae7023c

Browse files
committed
chore: improved validation and error responses
- Updated StatusCausesFromAPIStatus to also return causes from StatusReasonConflict - Enhanced PauseActionWorkspaceHandler and CreateWorkspaceHandler to utilize IsEOFError for better error responses. - Enhanced PauseActionWorkspaceHandler to ensure conflict_causes[] is present when Patch operation returns Invalid - Introduced new test files for response error handling and validation helper functions. Signed-off-by: Andy Stoneberg <[email protected]> update Signed-off-by: Andy Stoneberg <[email protected]>
1 parent 878e3b5 commit ae7023c

File tree

6 files changed

+1092
-5
lines changed

6 files changed

+1092
-5
lines changed

workspaces/backend/api/helpers.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"encoding/json"
2121
"errors"
2222
"fmt"
23+
"io"
2324
"mime"
2425
"net/http"
2526
"strings"
@@ -66,6 +67,13 @@ func (a *App) DecodeJSON(r *http.Request, v any) error {
6667
if a.IsMaxBytesError(err) {
6768
return err
6869
}
70+
71+
// provide better error message for the case where the body is empty
72+
// NOTE: io.EOF is only returned when the body is completely empty or contains only whitespace.
73+
// If there's any actual JSON content (even malformed), json.Decoder returns different errors.
74+
if a.IsEOFError(err) {
75+
return fmt.Errorf("request body was empty: %w", err)
76+
}
6977
return fmt.Errorf("error decoding JSON: %w", err)
7078
}
7179
return nil
@@ -77,6 +85,14 @@ func (a *App) IsMaxBytesError(err error) bool {
7785
return errors.As(err, &maxBytesError)
7886
}
7987

88+
// IsEOFError checks if the error is an EOF error (empty request body).
89+
// This returns true when the request body is completely empty, which happens when:
90+
// - Content-Length is 0, or
91+
// - The body stream ends immediately without any data (io.EOF)
92+
func (a *App) IsEOFError(err error) bool {
93+
return errors.Is(err, io.EOF)
94+
}
95+
8096
// ValidateContentType validates the Content-Type header of the request.
8197
// If this method returns false, the request has been handled and the caller should return immediately.
8298
// If this method returns true, the request has the correct Content-Type.
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
/*
2+
Copyright 2024.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package api
18+
19+
import (
20+
"encoding/json"
21+
"net/http"
22+
"net/http/httptest"
23+
"strconv"
24+
25+
. "github.com/onsi/ginkgo/v2"
26+
. "github.com/onsi/gomega"
27+
apierrors "k8s.io/apimachinery/pkg/api/errors"
28+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
29+
"k8s.io/apimachinery/pkg/runtime/schema"
30+
"k8s.io/apimachinery/pkg/util/validation/field"
31+
)
32+
33+
var _ = Describe("Error Response Functions", func() {
34+
35+
// Shared constants (used across multiple Describe blocks or in BeforeEach)
36+
const (
37+
testRequestPath = "/test"
38+
testRequestMethod = "GET"
39+
40+
// Used in both failedValidationResponse and Error response structure
41+
testFieldName = "name"
42+
testNameRequiredMsg = "name is required"
43+
)
44+
45+
var (
46+
app *App
47+
w *httptest.ResponseRecorder
48+
r *http.Request
49+
)
50+
51+
BeforeEach(func() {
52+
app = &App{}
53+
w = httptest.NewRecorder()
54+
r = httptest.NewRequest(testRequestMethod, testRequestPath, http.NoBody)
55+
})
56+
57+
Describe("conflictResponse", func() {
58+
const (
59+
httpStatusCodeConflict = http.StatusConflict
60+
61+
testGroupResourceGroup = "test"
62+
testGroupResourceResource = "tests"
63+
testResourceName = "test-name"
64+
testConflictMsg = "WorkspaceKind is used by 5 workspace(s)"
65+
)
66+
67+
var httpStatusCodeConflictStr = strconv.Itoa(http.StatusConflict)
68+
69+
type testCase struct {
70+
description string
71+
err error
72+
k8sCauses []metav1.StatusCause
73+
}
74+
75+
conflictErr := apierrors.NewConflict(
76+
schema.GroupResource{Group: testGroupResourceGroup, Resource: testGroupResourceResource},
77+
testResourceName,
78+
nil,
79+
)
80+
81+
testCases := []testCase{
82+
{
83+
description: "should return 409 with k8s causes",
84+
err: conflictErr,
85+
k8sCauses: []metav1.StatusCause{{Type: metav1.CauseTypeFieldValueInvalid, Message: testConflictMsg}},
86+
},
87+
{
88+
description: "should return 409 with error message when no k8s causes",
89+
err: conflictErr,
90+
k8sCauses: nil,
91+
},
92+
{
93+
description: "should return 409 with empty conflict causes array",
94+
err: conflictErr,
95+
k8sCauses: []metav1.StatusCause{},
96+
},
97+
}
98+
99+
buildExpectedConflictResponse := func(err error, k8sCauses []metav1.StatusCause, codeStr string) ErrorResponse {
100+
expectedConflictCauses := make([]ConflictError, len(k8sCauses))
101+
for i, cause := range k8sCauses {
102+
expectedConflictCauses[i] = ConflictError{
103+
Origin: OriginKubernetes,
104+
Message: cause.Message,
105+
}
106+
}
107+
108+
// Determine message: if we have k8s causes, use generic message; otherwise use error message
109+
var expectedMessage string
110+
if len(k8sCauses) > 0 {
111+
expectedMessage = errMsgKubernetesConflict
112+
} else {
113+
expectedMessage = err.Error()
114+
}
115+
116+
// Empty slices become nil after JSON unmarshaling
117+
var expectedConflictCausesForComparison []ConflictError
118+
if len(expectedConflictCauses) > 0 {
119+
expectedConflictCausesForComparison = expectedConflictCauses
120+
} else {
121+
expectedConflictCausesForComparison = nil
122+
}
123+
124+
return ErrorResponse{
125+
Code: codeStr,
126+
Message: expectedMessage,
127+
Cause: &ErrorCause{
128+
ConflictCauses: expectedConflictCausesForComparison,
129+
},
130+
}
131+
}
132+
133+
for _, tc := range testCases {
134+
It(tc.description, func() {
135+
app.conflictResponse(w, r, tc.err, tc.k8sCauses)
136+
137+
Expect(w.Code).To(Equal(httpStatusCodeConflict))
138+
139+
var envelope ErrorEnvelope
140+
unmarshalErr := json.Unmarshal(w.Body.Bytes(), &envelope)
141+
Expect(unmarshalErr).NotTo(HaveOccurred())
142+
Expect(envelope.Error).NotTo(BeNil())
143+
144+
expectedErrorResponse := buildExpectedConflictResponse(tc.err, tc.k8sCauses, httpStatusCodeConflictStr)
145+
Expect(envelope.Error.ErrorResponse).To(Equal(expectedErrorResponse))
146+
})
147+
}
148+
})
149+
150+
Describe("failedValidationResponse", func() {
151+
const (
152+
httpStatusCodeUnprocessableEntity = http.StatusUnprocessableEntity
153+
154+
testFieldKind = "kind"
155+
testFieldSpecName = "spec.name"
156+
testFieldSpecKind = "spec.kind"
157+
testInvalidKind = "invalid-kind"
158+
testKindInvalidMsg = "kind is invalid"
159+
)
160+
161+
var httpStatusCodeUnprocessableEntityStr = strconv.Itoa(http.StatusUnprocessableEntity)
162+
163+
type testCase struct {
164+
description string
165+
msg string
166+
valErrs field.ErrorList
167+
k8sCauses []metav1.StatusCause
168+
}
169+
170+
buildExpectedValidationResponse := func(msg string, valErrs field.ErrorList, k8sCauses []metav1.StatusCause, codeStr string) ErrorResponse {
171+
valErrsConverted := make([]ValidationError, len(valErrs))
172+
for i, err := range valErrs {
173+
valErrsConverted[i] = ValidationError{
174+
Origin: OriginInternal,
175+
Type: err.Type,
176+
Field: err.Field,
177+
Message: err.ErrorBody(),
178+
}
179+
}
180+
181+
k8sCausesConverted := make([]ValidationError, len(k8sCauses))
182+
for i, cause := range k8sCauses {
183+
k8sCausesConverted[i] = ValidationError{
184+
Origin: OriginKubernetes,
185+
Type: field.ErrorType(cause.Type),
186+
Field: cause.Field,
187+
Message: cause.Message,
188+
}
189+
}
190+
191+
expectedValidationErrors := make([]ValidationError, len(valErrsConverted)+len(k8sCausesConverted))
192+
copy(expectedValidationErrors, valErrsConverted)
193+
copy(expectedValidationErrors[len(valErrsConverted):], k8sCausesConverted)
194+
195+
var expectedValidationErrorsForComparison []ValidationError
196+
if len(expectedValidationErrors) > 0 {
197+
expectedValidationErrorsForComparison = expectedValidationErrors
198+
} else {
199+
expectedValidationErrorsForComparison = nil
200+
}
201+
202+
return ErrorResponse{
203+
Code: codeStr,
204+
Message: msg,
205+
Cause: &ErrorCause{
206+
ValidationErrors: expectedValidationErrorsForComparison,
207+
},
208+
}
209+
}
210+
211+
testCases := []testCase{
212+
{
213+
description: "should return 422 with internal validation errors",
214+
msg: errMsgRequestBodyInvalid,
215+
valErrs: field.ErrorList{
216+
field.Required(field.NewPath(testFieldName), ""),
217+
field.Invalid(field.NewPath(testFieldKind), testInvalidKind, testKindInvalidMsg),
218+
},
219+
k8sCauses: nil,
220+
},
221+
{
222+
description: "should return 422 with k8s validation errors",
223+
msg: errMsgKubernetesValidation,
224+
valErrs: nil,
225+
k8sCauses: []metav1.StatusCause{
226+
{
227+
Type: metav1.CauseTypeFieldValueRequired,
228+
Field: testFieldSpecName,
229+
Message: testNameRequiredMsg,
230+
},
231+
{
232+
Type: metav1.CauseTypeFieldValueInvalid,
233+
Field: testFieldSpecKind,
234+
Message: testKindInvalidMsg,
235+
},
236+
},
237+
},
238+
{
239+
description: "should return 422 with both internal and k8s validation errors",
240+
msg: errMsgRequestBodyInvalid,
241+
valErrs: field.ErrorList{
242+
field.Required(field.NewPath(testFieldName), ""),
243+
},
244+
k8sCauses: []metav1.StatusCause{
245+
{
246+
Type: metav1.CauseTypeFieldValueInvalid,
247+
Field: testFieldSpecKind,
248+
Message: testKindInvalidMsg,
249+
},
250+
},
251+
},
252+
{
253+
description: "should return 422 with empty validation errors",
254+
msg: errMsgRequestBodyInvalid,
255+
valErrs: nil,
256+
k8sCauses: nil,
257+
},
258+
}
259+
260+
for _, tc := range testCases {
261+
It(tc.description, func() {
262+
app.failedValidationResponse(w, r, tc.msg, tc.valErrs, tc.k8sCauses)
263+
264+
Expect(w.Code).To(Equal(httpStatusCodeUnprocessableEntity))
265+
266+
var envelope ErrorEnvelope
267+
err := json.Unmarshal(w.Body.Bytes(), &envelope)
268+
Expect(err).NotTo(HaveOccurred())
269+
Expect(envelope.Error).NotTo(BeNil())
270+
271+
expectedErrorResponse := buildExpectedValidationResponse(tc.msg, tc.valErrs, tc.k8sCauses, httpStatusCodeUnprocessableEntityStr)
272+
Expect(envelope.Error.ErrorResponse).To(Equal(expectedErrorResponse))
273+
})
274+
}
275+
})
276+
})

workspaces/backend/api/workspace_actions_handler.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ func (a *App) PauseActionWorkspaceHandler(w http.ResponseWriter, r *http.Request
7979
a.requestEntityTooLargeResponse(w, r, err)
8080
return
8181
}
82+
8283
//
8384
// TODO: handle UnmarshalTypeError and return 422,
8485
// decode the paths which were failed to decode (included in the error)
@@ -121,7 +122,13 @@ func (a *App) PauseActionWorkspaceHandler(w http.ResponseWriter, r *http.Request
121122
return
122123
}
123124
if errors.Is(err, repository.ErrWorkspaceInvalidState) {
124-
a.conflictResponse(w, r, err, nil)
125+
causes := helper.StatusCausesFromAPIStatus(err)
126+
// The patch can fail due to a 'test' operation that ensures the workspace is in a valid state for the action.
127+
// In these cases, Kubernetes returns an Invalid error but without StatusCauses.
128+
// We prepend a StatusCause with the `ErrWorkspaceInvalidState` error message to provide context,
129+
// but preserve any existing causes in case Kubernetes provides more details in the future.
130+
causes = append([]metav1.StatusCause{{Message: err.Error()}}, causes...)
131+
a.conflictResponse(w, r, err, causes)
125132
return
126133
}
127134
a.serverErrorResponse(w, r, err)

workspaces/backend/api/workspaces_handler.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ func (a *App) CreateWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps
227227
a.requestEntityTooLargeResponse(w, r, err)
228228
return
229229
}
230+
230231
//
231232
// TODO: handle UnmarshalTypeError and return 422,
232233
// decode the paths which were failed to decode (included in the error)

workspaces/backend/internal/helper/validation.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ import (
2929
"k8s.io/apimachinery/pkg/util/validation/field"
3030
)
3131

32-
// StatusCausesFromAPIStatus extracts status causes from a Kubernetes apierrors.APIStatus validation error.
33-
// NOTE: we use this to convert them to our own validation error format.
32+
// StatusCausesFromAPIStatus extracts status causes from a Kubernetes apierrors.APIStatus error.
33+
// NOTE: we use this to convert them to our own validation/conflict error format.
3434
func StatusCausesFromAPIStatus(err error) []metav1.StatusCause {
3535
// ensure this is an APIStatus error
3636
var statusErr apierrors.APIStatus
@@ -40,9 +40,9 @@ func StatusCausesFromAPIStatus(err error) []metav1.StatusCause {
4040
return nil
4141
}
4242

43-
// only attempt to extract if the status is a validation error
43+
// only attempt to extract causes if the status has details
4444
errStatus := statusErr.Status()
45-
if errStatus.Reason != metav1.StatusReasonInvalid {
45+
if errStatus.Details == nil {
4646
return nil
4747
}
4848

0 commit comments

Comments
 (0)