Skip to content

Commit 0667df9

Browse files
committed
fix: API token generation api responses refactoring
1 parent 66a85b1 commit 0667df9

File tree

6 files changed

+187
-62
lines changed

6 files changed

+187
-62
lines changed

api/apiToken/ApiTokenRestHandler.go

Lines changed: 6 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,6 @@ package apiToken
1818

1919
import (
2020
"encoding/json"
21-
"net/http"
22-
"strconv"
23-
"strings"
24-
"time"
25-
2621
openapi "github.com/devtron-labs/devtron/api/openapi/openapiClient"
2722
"github.com/devtron-labs/devtron/api/restHandler/common"
2823
"github.com/devtron-labs/devtron/pkg/apiToken"
@@ -32,6 +27,8 @@ import (
3227
"github.com/juju/errors"
3328
"go.uber.org/zap"
3429
"gopkg.in/go-playground/validator.v9"
30+
"net/http"
31+
"strconv"
3532
)
3633

3734
type ApiTokenRestHandler interface {
@@ -105,70 +102,23 @@ func (impl ApiTokenRestHandlerImpl) CreateApiToken(w http.ResponseWriter, r *htt
105102
err = decoder.Decode(&request)
106103
if err != nil {
107104
impl.logger.Errorw("err in decoding request in CreateApiToken", "err", err)
108-
common.WriteJsonResp(w, errors.New("invalid JSON payload"), nil, http.StatusBadRequest)
105+
common.WriteJsonResp(w, errors.New("invalid JSON payload "+err.Error()), nil, http.StatusBadRequest)
109106
return
110107
}
111108

112109
// validate request structure
113110
err = impl.validator.Struct(request)
114111
if err != nil {
115-
impl.logger.Errorw("validation err in CreateApiToken", "err", err, "request", request)
116-
common.WriteJsonResp(w, err, nil, http.StatusBadRequest)
117-
return
118-
}
119-
120-
// Comprehensive validation with specific error messages
121-
if request.Name == nil || *request.Name == "" {
122-
common.WriteJsonResp(w, errors.New("name field is required and cannot be empty"), nil, http.StatusBadRequest)
123-
return
124-
}
125-
126-
// Check name length (max 100 characters)
127-
if len(*request.Name) > 100 {
128-
common.WriteJsonResp(w, errors.New("name field cannot exceed 100 characters"), nil, http.StatusBadRequest)
112+
impl.logger.Errorw("validation err in CreateApiToken ", "err", err, "request", request)
113+
common.HandleValidationErrors(w, r, err)
129114
return
130115
}
131116

132-
// Check for invalid characters in name (spaces, commas)
133-
if strings.Contains(*request.Name, " ") || strings.Contains(*request.Name, ",") {
134-
common.WriteJsonResp(w, errors.New("name field cannot contain spaces or commas"), nil, http.StatusBadRequest)
135-
return
136-
}
137-
138-
// Check description length (max 350 characters as per UI)
139-
if request.Description != nil && len(*request.Description) > 350 {
140-
common.WriteJsonResp(w, errors.New("description field cannot exceed 350 characters"), nil, http.StatusBadRequest)
141-
return
142-
}
143-
144-
// Validate expireAtInMs field
145-
if request.ExpireAtInMs != nil {
146-
// Check if it's a valid positive timestamp
147-
if *request.ExpireAtInMs <= 0 {
148-
common.WriteJsonResp(w, errors.New("expireAtInMs must be a positive timestamp in milliseconds"), nil, http.StatusBadRequest)
149-
return
150-
}
151-
152-
// Check if it's not in the past (allow 1 minute buffer for clock skew)
153-
currentTime := time.Now().UnixMilli()
154-
if *request.ExpireAtInMs < (currentTime - 60000) {
155-
common.WriteJsonResp(w, errors.New("expireAtInMs cannot be in the past"), nil, http.StatusBadRequest)
156-
return
157-
}
158-
159-
// Check if it's not too far in the future (max 10 years)
160-
maxFutureTime := currentTime + (10 * 365 * 24 * 60 * 60 * 1000)
161-
if *request.ExpireAtInMs > maxFutureTime {
162-
common.WriteJsonResp(w, errors.New("expireAtInMs cannot be more than 10 years in the future"), nil, http.StatusBadRequest)
163-
return
164-
}
165-
}
166-
167117
// service call
168118
res, err := impl.apiTokenService.CreateApiToken(request, userId, impl.checkManagerAuth)
169119
if err != nil {
170120
impl.logger.Errorw("service err, CreateApiToken", "err", err, "payload", request)
171-
common.WriteJsonResp(w, err, nil, http.StatusInternalServerError)
121+
common.WriteJsonRespV2(w, err, nil, http.StatusInternalServerError)
172122
return
173123
}
174124
common.WriteJsonResp(w, err, res, http.StatusOK)

api/openapi/openapiClient/model_create_api_token_request.go

Lines changed: 3 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/restHandler/common/EnhancedErrorResponse.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@
1717
package common
1818

1919
import (
20+
"errors"
2021
"fmt"
2122
"github.com/devtron-labs/devtron/api/middleware"
2223
"github.com/devtron-labs/devtron/internal/util"
24+
"gopkg.in/go-playground/validator.v9"
2325
"net/http"
2426
"strconv"
2527
)
@@ -226,3 +228,41 @@ func HandleValidationError(w http.ResponseWriter, r *http.Request, fieldName, me
226228
apiErr := util.NewValidationErrorForField(fieldName, message)
227229
WriteJsonResp(w, apiErr, nil, apiErr.HttpStatusCode)
228230
}
231+
232+
// HandleValidationErrors handles multiple validation errors
233+
func HandleValidationErrors(w http.ResponseWriter, r *http.Request, err error) {
234+
// validator.ValidationErrors is a slice
235+
var vErrs validator.ValidationErrors
236+
if errors.As(err, &vErrs) {
237+
for _, fe := range vErrs {
238+
field := fe.Field()
239+
message := validationMessage(fe)
240+
HandleValidationError(w, r, field, message)
241+
return
242+
}
243+
}
244+
245+
// fallback: generic
246+
HandleValidationError(w, r, "request", "invalid request payload")
247+
}
248+
func validationMessage(fe validator.FieldError) string {
249+
switch fe.Tag() {
250+
// validation tag for api token name
251+
case "validate-api-token-name":
252+
return fmt.Sprintf(
253+
"%s must start and end with a lowercase letter or digit; may only contain lowercase letters, digits, '_' or '-' (no spaces or commas)",
254+
fe.Field(),
255+
)
256+
257+
// if a certain validator tag is not included in switch case then,
258+
// we will parse the error as generic validator error,
259+
// and further divide them on basis of parametric and non-parametric validation tags
260+
default:
261+
if fe.Param() != "" {
262+
// generic parametric fallback (e.g., min=3, max=50)
263+
return fmt.Sprintf("%s failed validation rule '%s=%s'", fe.Field(), fe.Tag(), fe.Param())
264+
}
265+
// generic non-parametric fallback (e.g., required, email, uuid)
266+
return fmt.Sprintf("%s failed validation rule '%s'", fe.Field(), fe.Tag())
267+
}
268+
}

api/restHandler/common/apiError.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,120 @@ func WriteJsonResp(w http.ResponseWriter, err error, respBody interface{}, statu
127127

128128
}
129129

130+
// This version will have more cases handled for like for 409(till now)
131+
// can be used in multiple places for suitable status code in response
132+
// Made a new version for backwards compatibility
133+
func WriteJsonRespV2(w http.ResponseWriter, err error, respBody interface{}, status int) {
134+
response := Response{}
135+
if err == nil {
136+
response.Result = respBody
137+
} else if apiErr, ok := err.(*util.ApiError); ok {
138+
response.Errors = []*util.ApiError{apiErr}
139+
if apiErr.HttpStatusCode != 0 {
140+
status = apiErr.HttpStatusCode
141+
}
142+
} else if validationErrs, ok := err.(validator.ValidationErrors); ok {
143+
var valErrors []*util.ApiError
144+
for _, validationErr := range validationErrs {
145+
//validationErr
146+
valErr := &util.ApiError{
147+
UserMessage: fmt.Sprint(validationErr),
148+
InternalMessage: fmt.Sprint(validationErr),
149+
}
150+
valErrors = append(valErrors, valErr)
151+
}
152+
response.Errors = valErrors
153+
} else if util.IsErrNoRows(err) {
154+
status = http.StatusNotFound
155+
// Try to extract resource context from respBody for better error messages
156+
resourceType, resourceId := extractResourceContext(respBody)
157+
if resourceType != "" && resourceId != "" {
158+
// Create context-aware resource not found error
159+
apiErr := util.NewResourceNotFoundError(resourceType, resourceId)
160+
response.Errors = []*util.ApiError{apiErr}
161+
} else {
162+
// Fallback to generic not found error (no more "pg: no rows in result set")
163+
apiErr := util.NewGenericResourceNotFoundError()
164+
response.Errors = []*util.ApiError{apiErr}
165+
}
166+
} else if util.IsResourceConflictError(err) {
167+
// Handles response for error due to resource with the same identifier already exists.
168+
status = http.StatusConflict
169+
// Try to extract resource context from respBody for better error messages
170+
resourceType, resourceId := extractResourceContext(respBody)
171+
if resourceType != "" && resourceId != "" {
172+
// Create context-aware resource duplicate error
173+
apiErr := util.NewDuplicateResourceError(resourceType, resourceId)
174+
response.Errors = []*util.ApiError{apiErr}
175+
} else {
176+
// Fallback to generic resource duplicate error
177+
apiErr := util.NewGenericDuplicateResourceError()
178+
response.Errors = []*util.ApiError{apiErr}
179+
}
180+
} else if multiErr, ok := err.(*multierror.Error); ok {
181+
var errorsResp []*util.ApiError
182+
for _, e := range multiErr.Errors {
183+
errorResp := &util.ApiError{
184+
UserMessage: e.Error(),
185+
InternalMessage: e.Error(),
186+
}
187+
errorsResp = append(errorsResp, errorResp)
188+
}
189+
response.Errors = errorsResp
190+
} else if errStatus, ok := err.(*errors2.StatusError); ok {
191+
apiErr := &util.ApiError{}
192+
apiErr.Code = strconv.Itoa(int(errStatus.ErrStatus.Code))
193+
apiErr.InternalMessage = errStatus.Error()
194+
apiErr.UserMessage = errStatus.Error()
195+
response.Errors = []*util.ApiError{apiErr}
196+
} else if unexpectedObjectError, ok := err.(*errors2.UnexpectedObjectError); ok {
197+
apiErr := &util.ApiError{}
198+
apiErr.InternalMessage = unexpectedObjectError.Error()
199+
apiErr.UserMessage = unexpectedObjectError.Error()
200+
response.Errors = []*util.ApiError{apiErr}
201+
} else {
202+
apiErr := &util.ApiError{}
203+
apiErr.Code = "000" // 000=unknown
204+
apiErr.InternalMessage = errors.Details(err)
205+
if respBody != nil {
206+
apiErr.UserMessage = respBody
207+
} else {
208+
apiErr.UserMessage = err.Error()
209+
}
210+
response.Errors = []*util.ApiError{apiErr}
211+
}
212+
response.Code = status //TODO : discuss with prashant about http status header
213+
response.Status = http.StatusText(status)
214+
215+
b, err := json.Marshal(response)
216+
if err != nil {
217+
util.GetLogger().Errorw("error in marshaling err object", "err", err)
218+
status = 500
219+
220+
response := Response{}
221+
apiErr := &util.ApiError{}
222+
apiErr.Code = "0000" // 000=unknown
223+
apiErr.InternalMessage = errors.Details(err)
224+
apiErr.UserMessage = "response marshaling error"
225+
response.Errors = []*util.ApiError{apiErr}
226+
b, err = json.Marshal(response)
227+
if err != nil {
228+
b = []byte("response marshaling error")
229+
util.GetLogger().Errorw("Unexpected error in apiError", "err", err)
230+
}
231+
}
232+
if status > 299 || err != nil {
233+
util.GetLogger().Infow("ERROR RES", "TYPE", "API-ERROR", "RES", response.Code, "err", err)
234+
}
235+
w.Header().Set(CONTENT_TYPE, APPLICATION_JSON)
236+
w.WriteHeader(status)
237+
_, err = w.Write(b)
238+
if err != nil {
239+
util.GetLogger().Error(err)
240+
}
241+
242+
}
243+
130244
// global response body used across api
131245
type Response struct {
132246
Code int `json:"code,omitempty"`

internal/util/ErrorUtil.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"google.golang.org/grpc/status"
2828
"net/http"
2929
"strconv"
30+
"strings"
3031
)
3132

3233
type ApiError struct {
@@ -89,7 +90,19 @@ func (e *ApiError) ErrorfUser(format string, a ...interface{}) error {
8990
func IsErrNoRows(err error) bool {
9091
return pg.ErrNoRows == err
9192
}
92-
93+
func IsResourceConflictError(err error) bool {
94+
var resourceConflictPhrases = []string{
95+
"already exists",
96+
"already used",
97+
}
98+
msg := err.Error()
99+
for _, phrase := range resourceConflictPhrases {
100+
if strings.Contains(msg, phrase) {
101+
return true
102+
}
103+
}
104+
return false
105+
}
93106
func GetClientErrorDetailedMessage(err error) string {
94107
if errStatus, ok := status.FromError(err); ok {
95108
return errStatus.Message()

internal/util/ResourceErrorFactory.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,13 @@ func NewGenericResourceNotFoundError() *ApiError {
8989
).WithCode(constants.ResourceNotFound).
9090
WithUserDetailMessage("The requested resource does not exist or has been deleted.")
9191
}
92+
93+
// NewGenericDuplicateResourceError creates a generic conflict error when a resource already exists
94+
func NewGenericDuplicateResourceError() *ApiError {
95+
return NewApiError(
96+
http.StatusConflict,
97+
"Resource already exists",
98+
"resource already exists",
99+
).WithCode(constants.DuplicateResource).
100+
WithUserDetailMessage("The resource you are trying to create already exists. Please use a different name or identifier.")
101+
}

0 commit comments

Comments
 (0)