Skip to content

Commit bf09b2a

Browse files
authored
fix: API token generation api responses refactoring (#6788)
* fix: API token generation api responses refactoring * fix: register custom validation against tag for api token name validations * fix: register custom validation against tag for api token name validations * Revert "fix: register custom validation against tag for api token name validations" This reverts commit 7593c27. * fix: remove `required` validation from Description and expiryAtInMs * fix: adding resource conflict api response in WriteJsonResp utility * fix: path params int validation updated to whole numbers only * fix: handled resource not found response for update and delete api, token
1 parent 6e2d95d commit bf09b2a

File tree

9 files changed

+96
-64
lines changed

9 files changed

+96
-64
lines changed

api/apiToken/ApiTokenRestHandler.go

Lines changed: 5 additions & 55 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,65 +102,18 @@ 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)
129-
return
130-
}
131-
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)
112+
impl.logger.Errorw("validation err in CreateApiToken ", "err", err, "request", request)
113+
common.HandleValidationErrors(w, r, err)
135114
return
136115
}
137116

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 {

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/ParamParserUtils.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ func convertToIntWithContext(w http.ResponseWriter, paramValue, paramName, resou
7171
return 0, apiErr
7272
}
7373

74-
if paramIntValue <= 0 {
74+
if paramIntValue < 0 {
7575
apiErr := util.NewValidationErrorForField(paramName, "must be a positive integer")
7676
WriteJsonResp(w, apiErr, nil, apiErr.HttpStatusCode)
7777
return 0, apiErr

api/restHandler/common/apiError.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,20 @@ func WriteJsonResp(w http.ResponseWriter, err error, respBody interface{}, statu
6363
apiErr := util.NewGenericResourceNotFoundError()
6464
response.Errors = []*util.ApiError{apiErr}
6565
}
66+
} else if util.IsResourceConflictError(err) {
67+
// Handles response for error due to resource with the same identifier already exists.
68+
status = http.StatusConflict
69+
// Try to extract resource context from respBody for better error messages
70+
resourceType, resourceId := extractResourceContext(respBody)
71+
if resourceType != "" && resourceId != "" {
72+
// Create context-aware resource duplicate error
73+
apiErr := util.NewDuplicateResourceError(resourceType, resourceId)
74+
response.Errors = []*util.ApiError{apiErr}
75+
} else {
76+
// Fallback to generic resource duplicate error
77+
apiErr := util.NewGenericDuplicateResourceError()
78+
response.Errors = []*util.ApiError{apiErr}
79+
}
6680
} else if multiErr, ok := err.(*multierror.Error); ok {
6781
var errorsResp []*util.ApiError
6882
for _, e := range multiErr.Errors {

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+
}

internal/util/ValidateUtil.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ func IntValidator() (*validator.Validate, error) {
107107
if err != nil {
108108
return v, err
109109
}
110+
err = v.RegisterValidation("validate-api-token-name", validateApiTokenName)
110111
return v, err
111112
}
112113

@@ -140,3 +141,9 @@ func validateGlobalEntityName(fl validator.FieldLevel) bool {
140141
hostnameRegexRFC952 := regexp.MustCompile(hostnameRegexString)
141142
return hostnameRegexRFC952.MatchString(fl.Field().String())
142143
}
144+
145+
func validateApiTokenName(fl validator.FieldLevel) bool {
146+
hostnameRegexString := `^[a-z0-9][a-z0-9_-]*[a-z0-9]$`
147+
hostnameRegexRFC952 := regexp.MustCompile(hostnameRegexString)
148+
return hostnameRegexRFC952.MatchString(fl.Field().String())
149+
}

pkg/apiToken/ApiTokenService.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,7 @@ func (impl ApiTokenServiceImpl) UpdateApiToken(apiTokenId int, request *openapi.
313313
return nil, err
314314
}
315315
if apiToken == nil || apiToken.Id == 0 {
316-
return nil, errors.New(fmt.Sprintf("api-token corresponds to apiTokenId '%d' is not found", apiTokenId))
316+
return nil, pg.ErrNoRows
317317
}
318318

319319
previousTokenVersion := apiToken.Version
@@ -361,7 +361,7 @@ func (impl ApiTokenServiceImpl) DeleteApiToken(apiTokenId int, deletedBy int32)
361361
return nil, err
362362
}
363363
if apiToken == nil || apiToken.Id == 0 {
364-
return nil, errors.New(fmt.Sprintf("api-token corresponds to apiTokenId '%d' is not found", apiTokenId))
364+
return nil, pg.ErrNoRows
365365
}
366366

367367
apiToken.ExpireAtInMs = time.Now().UnixMilli()

0 commit comments

Comments
 (0)