File size: 6,184 Bytes
fea99b3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
package commonhttp

import (
	"context"
	"errors"
	"log/slog"
	"net/http"

	"github.com/samber/lo"

	"github.com/openmeterio/openmeter/pkg/models"
)

type ErrorWithHTTPStatusCode struct {
	error
	StatusCode int
	Extensions []ExtendProblemFunc
}

func ExtendProblem(name, details string) ExtendProblemFunc {
	return func() map[string]interface{} {
		return map[string]interface{}{
			name: details,
		}
	}
}

type ExtendProblemFunc func() map[string]interface{}

func (e ExtendProblemFunc) apply(extensions map[string]interface{}) {
	for k, v := range e() {
		extensions[k] = v
	}
}

func NewHTTPError(statusCode int, err error, extensions ...ExtendProblemFunc) ErrorWithHTTPStatusCode {
	return ErrorWithHTTPStatusCode{
		StatusCode: statusCode,
		error:      err,
		Extensions: extensions,
	}
}

func (e ErrorWithHTTPStatusCode) EncodeError(ctx context.Context, w http.ResponseWriter) bool {
	problem := models.NewStatusProblem(ctx, e.error, e.StatusCode)
	for _, ext := range e.Extensions {
		ext.apply(problem.Extensions)
	}
	problem.Respond(w)
	return true
}

// ErrorEncoder encodes an error as HTTP 500 Internal Server Error.
func ErrorEncoder(ctx context.Context, _ error, w http.ResponseWriter) bool {
	models.NewStatusProblem(ctx, errors.New("something went wrong"), http.StatusInternalServerError).Respond(w)

	return false
}

// HandleErrorIfTypeMatches checks if the error is of the given type and encodes it as an HTTP error.
// Using the generic feature we can mandate that the error implements the error interface. This is a
// must, as the errors.As would panic if the error does not implement the error interface.
func HandleErrorIfTypeMatches[T error](ctx context.Context, statusCode int, err error, w http.ResponseWriter, extendedProblemFunc ...func(T) map[string]interface{}) bool {
	if err, ok := lo.ErrorsAs[T](err); ok {
		extendedProblemFuncs := make([]ExtendProblemFunc, 0, len(extendedProblemFunc))
		for _, f := range extendedProblemFunc {
			extendedProblemFuncs = append(extendedProblemFuncs, func() map[string]interface{} {
				return f(err)
			})
		}
		NewHTTPError(statusCode, err, extendedProblemFuncs...).EncodeError(ctx, w)
		return true
	}

	return false
}

type httpAttributeKey string

const httpStatusCodeErrorAttribute httpAttributeKey = "openmeter.http.status_code"

func WithHTTPStatusCodeAttribute(code int) models.ValidationIssueOption {
	return models.WithAttribute(httpStatusCodeErrorAttribute, code)
}

// Options for the Handler methods
type handleIssueIfHTTPStatusKnownOptions struct {
	statusPriorizationBehavior HTTPStatusAttributePriorizationBehavior
}

type HTTPStatusAttributePriorizationBehavior string

const (
	// In case there are multiple status codes, the errors won't be mapped. This is the default and recommended behavior
	HTTPStatusAttributePriorizationBehaviorSingular HTTPStatusAttributePriorizationBehavior = "singular"

	// This below options could be added but I fear they'd only cause more confusion
	//
	// // In case of conflicts, the status of the first error will be used
	// HTTPStatusAttributePriorizationBehaviorFirst HTTPStatusAttributePriorizationBehavior = "first"
	// // In case of conflicts, if there's an auth-authz error, it will be used, otherwise the status of the first error will be used
	// HTTPStatusAttributePriorizationBehaviorAuthAuthz HTTPStatusAttributePriorizationBehavior = "auth-authz"
)

type HandleIssueIfHTTPStatusKnownOptions func(*handleIssueIfHTTPStatusKnownOptions)

func WithHTTPStatusAttributePriorizationBehavior(behavior HTTPStatusAttributePriorizationBehavior) HandleIssueIfHTTPStatusKnownOptions {
	return func(o *handleIssueIfHTTPStatusKnownOptions) {
		o.statusPriorizationBehavior = behavior
	}
}

func HandleIssueIfHTTPStatusKnown(ctx context.Context, err error, w http.ResponseWriter, options ...HandleIssueIfHTTPStatusKnownOptions) bool {
	opts := &handleIssueIfHTTPStatusKnownOptions{
		statusPriorizationBehavior: HTTPStatusAttributePriorizationBehaviorSingular,
	}
	for _, opt := range options {
		opt(opts)
	}

	issues, err := models.AsValidationIssues(err)
	if err != nil {
		return false
	}

	if len(issues) == 0 {
		return false
	}

	issuesByCodeMap := make(map[int]models.ValidationIssues)

	for _, issue := range issues {
		code, ok := issue.Attributes()[httpStatusCodeErrorAttribute]
		if !ok {
			continue
		}

		issueCode, ok := code.(int)
		if !ok {
			slog.Default().DebugContext(ctx, "issue does have HTTP status code attribute but it's not an integer", "issue", issue)
			continue
		}

		issues := issuesByCodeMap[issueCode]
		issues = append(issues, issue)
		issuesByCodeMap[issueCode] = issues
	}

	if len(issuesByCodeMap) == 0 {
		return false
	}

	extendProblemFuncs := make([]ExtendProblemFunc, 0)
	responseStatusCode := 500 // default to internal server error

	switch opts.statusPriorizationBehavior {
	case HTTPStatusAttributePriorizationBehaviorSingular:
		if len(issuesByCodeMap) > 1 {
			return false
		}

		for code, issues := range issuesByCodeMap {
			responseStatusCode = code
			extendProblemFuncs = append(extendProblemFuncs, func() map[string]interface{} {
				return map[string]interface{}{
					// FIXME[galexi,chrisgacsal]: having everything under "validationErrors" makes no sense but we need it for backwards compatibility, otherwise its just hacky...
					// should migrate to more generic form like "errors"
					"validationErrors": lo.Map(issues, func(issue models.ValidationIssue, _ int) map[string]interface{} {
						// We don't want to expose private attributes to the client
						attrs := issue.Attributes()
						delete(attrs, httpStatusCodeErrorAttribute)
						delete(attrs, issue.Code())
						issue = issue.SetAttributes(attrs)

						return issue.AsErrorExtension()
					}),
				}
			})
		}
	default:
		slog.Default().ErrorContext(ctx, "Unknown HTTP status attribute priorization behavior, passing to next error handler", "behavior", opts.statusPriorizationBehavior)
		return false
	}

	// The returned error message will be a joined error(ValidationIssues.AsError().Error()), not sure if this is the best approach
	NewHTTPError(responseStatusCode, err, extendProblemFuncs...).EncodeError(ctx, w)

	return true
}