File size: 6,240 Bytes
04f1444
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
package apierrors

import (
	"context"
	"errors"
	"net/http"
	"strings"

	api "github.com/openmeterio/openmeter/api/v3"
	"github.com/openmeterio/openmeter/pkg/errorsx"
	"github.com/openmeterio/openmeter/pkg/models"
)

const httpStatusCodeErrorAttribute = "openmeter.http.status_code"

// NewV3ErrorHandlerFunc returns an oapi-codegen ChiServerOptions.ErrorHandlerFunc implementation.
//
// It is invoked when the generated router fails request binding (query/path/header parsing).
// The main purpose is to ensure we always write a response (otherwise net/http defaults to 200 with
// an empty body), and to keep error-to-status mapping consistent with our model error types.
func NewV3ErrorHandlerFunc(logger errorsx.Handler) func(w http.ResponseWriter, r *http.Request, err error) {
	return func(w http.ResponseWriter, r *http.Request, err error) {
		if err == nil {
			return
		}

		// If it's already a v3 API error, just render it.
		var apiErr *BaseAPIError
		if errors.As(err, &apiErr) {
			apiErr.HandleAPIError(w, r)
			return
		}

		ctx := r.Context()

		// Request binding errors produced by the generated v3 router.
		// Convert them into v3 InvalidParameters so the response is actionable for clients.
		if invalidParams, ok := invalidParametersFromGeneratedRouterError(err); ok {
			logger.HandleContext(ctx, err)
			NewBadRequestError(ctx, err, invalidParams).HandleAPIError(w, r)
			return
		}

		// Mirror commonhttp.GenericErrorEncoder's ordering, but render using v3 apierrors.
		if status, ok := singularHTTPStatusFromValidationIssues(err); ok {
			if mapped := apiErrorFromHTTPStatus(ctx, status, err); mapped != nil {
				logger.HandleContext(ctx, err)
				mapped.HandleAPIError(w, r)
				return
			}
		}

		// Default: classify as validation error (400) for request binding failures.
		validationErr := models.NewGenericValidationError(err)
		logger.HandleContext(r.Context(), validationErr)
		NewBadRequestError(r.Context(), validationErr, nil).HandleAPIError(w, r)
	}
}

func invalidParametersFromGeneratedRouterError(err error) (InvalidParameters, bool) {
	// These types are defined in api/v3/api.gen.go.
	//
	// Note: those errors do not carry the parameter location (query/path/header) except for the
	// dedicated "required header" variant, so we default to "query" where ambiguous. This is still
	// a major improvement over returning an empty error response.
	var invalidFormat *api.InvalidParamFormatError
	if errors.As(err, &invalidFormat) {
		field := enrichFieldFromBindError(invalidFormat.ParamName, invalidFormat.Err.Error())
		return InvalidParameters{
			{
				Field:  field,
				Rule:   "format",
				Reason: invalidFormat.Err.Error(),
				Source: InvalidParamSourceQuery,
			},
		}, true
	}

	var requiredParam *api.RequiredParamError
	if errors.As(err, &requiredParam) {
		return InvalidParameters{
			{
				Field:  requiredParam.ParamName,
				Rule:   "required",
				Reason: "is required",
				Source: InvalidParamSourceQuery,
			},
		}, true
	}

	var requiredHeader *api.RequiredHeaderError
	if errors.As(err, &requiredHeader) {
		return InvalidParameters{
			{
				Field:  requiredHeader.ParamName,
				Rule:   "required",
				Reason: "is required",
				Source: InvalidParamSourceHeader,
			},
		}, true
	}

	var tooMany *api.TooManyValuesForParamError
	if errors.As(err, &tooMany) {
		return InvalidParameters{
			{
				Field:  tooMany.ParamName,
				Rule:   "too_many_values",
				Reason: tooMany.Error(),
				Source: InvalidParamSourceQuery,
			},
		}, true
	}

	var unmarshal *api.UnmarshalingParamError
	if errors.As(err, &unmarshal) {
		return InvalidParameters{
			{
				Field:  unmarshal.ParamName,
				Rule:   "unmarshal",
				Reason: unmarshal.Err.Error(),
				Source: InvalidParamSourceQuery,
			},
		}, true
	}

	var unescapedCookie *api.UnescapedCookieParamError
	if errors.As(err, &unescapedCookie) {
		return InvalidParameters{
			{
				Field:  unescapedCookie.ParamName,
				Rule:   "unescape",
				Reason: unescapedCookie.Error(),
				Source: InvalidParamSourceHeader,
			},
		}, true
	}

	return nil, false
}

func enrichFieldFromBindError(paramName string, bindErrMsg string) string {
	// oapi-codegen deepObject binding errors (runtime.BindQueryParameter) can be more specific than
	// just the outer parameter name, e.g.:
	// "error assigning value to destination: field [sizee] is not present in destination object".
	//
	// For nicer AIP errors, return "page.sizee" instead of just "page".
	if paramName == "" || bindErrMsg == "" {
		return paramName
	}
	if strings.Contains(paramName, "[") {
		// Already specific (e.g. "page[size]") - keep as-is.
		return paramName
	}
	const needle = "field ["
	i := strings.Index(bindErrMsg, needle)
	if i == -1 {
		return paramName
	}
	rest := bindErrMsg[i+len(needle):]
	j := strings.Index(rest, "]")
	if j == -1 {
		return paramName
	}
	field := rest[:j]
	if field == "" {
		return paramName
	}
	return paramName + "." + field
}

func singularHTTPStatusFromValidationIssues(err error) (int, bool) {
	issues, _ := models.AsValidationIssues(err)
	if len(issues) == 0 {
		return 0, false
	}

	// We intentionally mirror commonhttp.HandleIssueIfHTTPStatusKnown's "singular" behavior:
	// if multiple status codes are present, we don't map.
	codes := make(map[int]struct{}, 1)
	for _, issue := range issues {
		raw, ok := issue.Attributes()[httpStatusCodeErrorAttribute]
		if !ok {
			continue
		}
		c, ok := raw.(int)
		if !ok {
			continue
		}
		codes[c] = struct{}{}
	}

	if len(codes) != 1 {
		return 0, false
	}

	for c := range codes {
		return c, true
	}
	return 0, false
}

func apiErrorFromHTTPStatus(ctx context.Context, status int, err error) *BaseAPIError {
	switch status {
	case http.StatusBadRequest:
		return NewBadRequestError(ctx, err, nil)
	case http.StatusUnauthorized:
		return NewUnauthenticatedError(ctx, err)
	case http.StatusForbidden:
		return NewForbiddenError(ctx, err)
	case http.StatusNotFound:
		return NewNotFoundError(ctx, err, "")
	case http.StatusConflict:
		return NewConflictError(ctx, err, err.Error())
	case http.StatusPreconditionFailed:
		return NewPreconditionFailedError(ctx, err.Error())
	case http.StatusNotImplemented:
		return NewNotImplementedError(ctx, err)
	default:
		return nil
	}
}