fleet/server/service/middleware/endpoint_utils/endpoint_utils.go
Victor Lyuboslavsky cdd57fc206
Removing dependencies from endpoint_utils (#36672)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #36670

Refactoring `middleware/endpoint_utils` package to remove direct
dependencies on:
- fleet.Service
- android.Service

Specific changes are:
- replace AuthFunc+FleetService with AuthMiddleware
- Move the definition of handler functions to the respective services
and use a generic `CommonEndpointer[H any] struct`

Although this was discovered as part of Activity bounded context
research, this change is not directly related to bounded contexts.

In retrospect, this decoupling should have been done when creating the
Android service for ADR-0001.

## Testing

- [x] QA'd all new/changed functionality manually



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Refactor**
* Internal restructuring of endpoint handling and authentication
middleware composition to improve code maintainability and type safety.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-08 09:05:02 -06:00

791 lines
23 KiB
Go

package endpoint_utils
import (
"bufio"
"compress/gzip"
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"reflect"
"strconv"
"strings"
"time"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/contexts/license"
"github.com/fleetdm/fleet/v4/server/contexts/logging"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/service/middleware/authzcheck"
"github.com/fleetdm/fleet/v4/server/service/middleware/ratelimit"
"github.com/go-kit/kit/endpoint"
kithttp "github.com/go-kit/kit/transport/http"
"github.com/go-kit/log"
"github.com/go-kit/log/level"
"github.com/gorilla/mux"
)
type HandlerRoutesFunc func(r *mux.Router, opts []kithttp.ServerOption)
// ParseTag parses a `url` tag and whether it's optional or not, which is an optional part of the tag
func ParseTag(tag string) (string, bool, error) {
parts := strings.Split(tag, ",")
switch len(parts) {
case 0:
return "", false, fmt.Errorf("Error parsing %s: too few parts", tag)
case 1:
return tag, false, nil
case 2:
return parts[0], parts[1] == "optional", nil
default:
return "", false, fmt.Errorf("Error parsing %s: too many parts", tag)
}
}
type fieldPair struct {
Sf reflect.StructField
V reflect.Value
}
// allFields returns all the fields for a struct, including the ones from embedded structs
func allFields(ifv reflect.Value) []fieldPair {
if ifv.Kind() == reflect.Ptr {
ifv = ifv.Elem()
}
if ifv.Kind() != reflect.Struct {
return nil
}
var fields []fieldPair
if !ifv.IsValid() {
return nil
}
t := ifv.Type()
for i := 0; i < ifv.NumField(); i++ {
v := ifv.Field(i)
if v.Kind() == reflect.Struct && t.Field(i).Anonymous {
fields = append(fields, allFields(v)...)
continue
}
fields = append(fields, fieldPair{Sf: ifv.Type().Field(i), V: v})
}
return fields
}
func BadRequestErr(publicMsg string, internalErr error) error {
// ensure timeout errors don't become BadRequestErrors.
var opErr *net.OpError
if errors.As(internalErr, &opErr) {
return fmt.Errorf(publicMsg+", internal: %w", internalErr)
}
return &fleet.BadRequestError{
Message: publicMsg,
InternalErr: internalErr,
}
}
func UintFromRequest(r *http.Request, name string) (uint64, error) {
vars := mux.Vars(r)
s, ok := vars[name]
if !ok {
return 0, ErrBadRoute
}
u, err := strconv.ParseUint(s, 10, 64)
if err != nil {
return 0, ctxerr.Wrap(r.Context(), err, "UintFromRequest")
}
return u, nil
}
func IntFromRequest(r *http.Request, name string) (int64, error) {
vars := mux.Vars(r)
s, ok := vars[name]
if !ok {
return 0, ErrBadRoute
}
u, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return 0, ctxerr.Wrap(r.Context(), err, "IntFromRequest")
}
return u, nil
}
func StringFromRequest(r *http.Request, name string) (string, error) {
vars := mux.Vars(r)
s, ok := vars[name]
if !ok {
return "", ErrBadRoute
}
unescaped, err := url.PathUnescape(s)
if err != nil {
return "", ctxerr.Wrap(r.Context(), err, "unescape value in path")
}
return unescaped, nil
}
func DecodeURLTagValue(r *http.Request, field reflect.Value, urlTagValue string, optional bool) error {
switch field.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
v, err := IntFromRequest(r, urlTagValue)
if err != nil {
if errors.Is(err, ErrBadRoute) && optional {
return nil
}
return BadRequestErr("IntFromRequest", err)
}
field.SetInt(v)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
v, err := UintFromRequest(r, urlTagValue)
if err != nil {
if errors.Is(err, ErrBadRoute) && optional {
return nil
}
return BadRequestErr("UintFromRequest", err)
}
field.SetUint(v)
case reflect.String:
v, err := StringFromRequest(r, urlTagValue)
if err != nil {
if errors.Is(err, ErrBadRoute) && optional {
return nil
}
return BadRequestErr("StringFromRequest", err)
}
field.SetString(v)
default:
return fmt.Errorf("unsupported type for field %s for 'url' decoding: %s", urlTagValue, field.Kind())
}
return nil
}
func DecodeQueryTagValue(r *http.Request, fp fieldPair) error {
queryTagValue, ok := fp.Sf.Tag.Lookup("query")
if ok {
var err error
var optional bool
queryTagValue, optional, err = ParseTag(queryTagValue)
if err != nil {
return err
}
queryVal := r.URL.Query().Get(queryTagValue)
// if optional and it's a ptr, leave as nil
if queryVal == "" {
if optional {
return nil
}
return &fleet.BadRequestError{Message: fmt.Sprintf("Param %s is required", queryTagValue)}
}
field := fp.V
if field.Kind() == reflect.Ptr {
// create the new instance of whatever it is
field.Set(reflect.New(field.Type().Elem()))
field = field.Elem()
}
switch field.Kind() {
case reflect.String:
field.SetString(queryVal)
case reflect.Uint:
queryValUint, err := strconv.Atoi(queryVal)
if err != nil {
return BadRequestErr("parsing uint from query", err)
}
field.SetUint(uint64(queryValUint)) //nolint:gosec // dismiss G115
case reflect.Float64:
queryValFloat, err := strconv.ParseFloat(queryVal, 64)
if err != nil {
return BadRequestErr("parsing float from query", err)
}
field.SetFloat(queryValFloat)
case reflect.Bool:
field.SetBool(queryVal == "1" || queryVal == "true")
case reflect.Int:
queryValInt := 0
switch queryTagValue {
case "order_direction", "inherited_order_direction":
switch queryVal {
case "desc":
queryValInt = int(fleet.OrderDescending)
case "asc":
queryValInt = int(fleet.OrderAscending)
default:
return &fleet.BadRequestError{Message: "unknown order_direction: " + queryVal}
}
default:
queryValInt, err = strconv.Atoi(queryVal)
if err != nil {
return BadRequestErr("parsing int from query", err)
}
}
field.SetInt(int64(queryValInt))
default:
return fmt.Errorf("Cant handle type for field %s %s", fp.Sf.Name, field.Kind())
}
}
return nil
}
// copied from https://github.com/go-chi/chi/blob/c97bc988430d623a14f50b7019fb40529036a35a/middleware/realip.go#L42
var (
trueClientIP = http.CanonicalHeaderKey("True-Client-IP")
xForwardedFor = http.CanonicalHeaderKey("X-Forwarded-For")
xRealIP = http.CanonicalHeaderKey("X-Real-IP")
)
func ExtractIP(r *http.Request) string {
ip := r.RemoteAddr
if i := strings.LastIndexByte(ip, ':'); i != -1 {
ip = ip[:i]
}
// Prefer True-Client-IP and X-Real-IP headers before X-Forwarded-For:
// - True-Client-IP: set by some CDNs (e.g., Akamai) to indicate the real client IP early in the chain
// - X-Real-IP: set by Nginx or similar proxies as a simpler alternative to X-Forwarded-For
// These headers are less likely to be spoofed or malformed compared to X-Forwarded-For.
if tcip := r.Header.Get(trueClientIP); tcip != "" {
ip = tcip
} else if xrip := r.Header.Get(xRealIP); xrip != "" {
ip = xrip
} else if xff := r.Header.Get(xForwardedFor); xff != "" {
// X-Forwarded-For is a comma-separated list of IP addresses representing the chain of proxies
// that a request has passed through. This is not a standard, but a convention.
// The convention is to treat the left-most IP address as the original client IP.
// For example:
// X-Forwarded-For: 198.51.100.1, 203.0.113.5, 127.0.0.1
// Means:
// - 198.51.100.1 is the client IP
// - 127.0.0.1 is the last proxy (likely this server or a local proxy)
//
// If the left-most IP is a private or loopback address (e.g., 127.0.0.1 or 10.x.x.x), it may indicate:
// - The request originated from a local proxy, or
// - The header was spoofed by a client (untrusted source)
//
// Having multiple X-Forwarded-For headers is non-standard, so we do not handle it here.
//
// Here, we grab the left-most IP address by convention.
i := strings.Index(xff, ",")
if i == -1 {
i = len(xff)
}
ip = xff[:i]
}
return ip
}
type ErrorHandler struct {
Logger log.Logger
}
func (h *ErrorHandler) Handle(ctx context.Context, err error) {
// get the request path
path, _ := ctx.Value(kithttp.ContextKeyRequestPath).(string)
logger := level.Info(log.With(h.Logger, "path", path))
if startTime, ok := logging.StartTime(ctx); ok && !startTime.IsZero() {
logger = log.With(logger, "took", time.Since(startTime))
}
var ewi fleet.ErrWithInternal
if errors.As(err, &ewi) {
logger = log.With(logger, "internal", ewi.Internal())
}
var ewlf fleet.ErrWithLogFields
if errors.As(err, &ewlf) {
logger = log.With(logger, ewlf.LogFields()...)
}
var uuider fleet.ErrorUUIDer
if errors.As(err, &uuider) {
logger = log.With(logger, "uuid", uuider.UUID())
}
var rle ratelimit.Error
if errors.As(err, &rle) {
res := rle.Result()
if res.RetryAfter > 0 {
logger = log.With(logger, "retry_after", res.RetryAfter)
}
logger.Log("err", "limit exceeded")
} else {
logger.Log("err", err)
}
}
// A value that implements RequestDecoder takes control of decoding the request
// as a whole - that is, it is responsible for decoding the body and any url
// or query argument itself.
type RequestDecoder interface {
DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error)
}
// A value that implements requestValidator is called after having the values
// decoded into it to apply further validations.
type requestValidator interface {
ValidateRequest() error
}
// MakeDecoder creates a decoder for the type for the struct passed on. If the
// struct has at least 1 json tag it'll unmarshall the body. If the struct has
// a `url` tag with value list_options it'll gather fleet.ListOptions from the
// URL (similarly for host_options, carve_options, user_options that derive
// from the common list_options). Note that these behaviors do not work for embedded structs.
//
// Finally, any other `url` tag will be treated as a path variable (of the form
// /path/{name} in the route's path) from the URL path pattern, and it'll be
// decoded and set accordingly. Variables can be optional by setting the tag as
// follows: `url:"some-id,optional"`.
// The "list_options" are optional by default and it'll ignore the optional
// portion of the tag.
//
// If iface implements the RequestDecoder interface, it returns a function that
// calls iface.DecodeRequest(ctx, r) - i.e. the value itself fully controls its
// own decoding.
//
// If iface implements the bodyDecoder interface, it calls iface.DecodeBody
// after having decoded any non-body fields (such as url and query parameters)
// into the struct.
func MakeDecoder(
iface interface{},
jsonUnmarshal func(body io.Reader, req any) error,
parseCustomTags func(urlTagValue string, r *http.Request, field reflect.Value) (bool, error),
isBodyDecoder func(reflect.Value) bool,
decodeBody func(ctx context.Context, r *http.Request, v reflect.Value, body io.Reader) error,
) kithttp.DecodeRequestFunc {
if iface == nil {
return func(ctx context.Context, r *http.Request) (interface{}, error) {
return nil, nil
}
}
if rd, ok := iface.(RequestDecoder); ok {
return func(ctx context.Context, r *http.Request) (interface{}, error) {
return rd.DecodeRequest(ctx, r)
}
}
t := reflect.TypeOf(iface)
if t.Kind() != reflect.Struct {
panic(fmt.Sprintf("MakeDecoder only understands structs, not %T", iface))
}
return func(ctx context.Context, r *http.Request) (interface{}, error) {
v := reflect.New(t)
nilBody := false
buf := bufio.NewReader(r.Body)
var body io.Reader = buf
if _, err := buf.Peek(1); err == io.EOF {
nilBody = true
} else {
if r.Header.Get("content-encoding") == "gzip" {
gzr, err := gzip.NewReader(buf)
if err != nil {
return nil, BadRequestErr("gzip decoder error", err)
}
defer gzr.Close()
body = gzr
}
if isBodyDecoder == nil || !isBodyDecoder(v) {
req := v.Interface()
err := jsonUnmarshal(body, req)
if err != nil {
return nil, BadRequestErr("json decoder error", err)
}
v = reflect.ValueOf(req)
}
}
fields := allFields(v)
for _, fp := range fields {
field := fp.V
urlTagValue, ok := fp.Sf.Tag.Lookup("url")
var err error
if ok {
optional := false
urlTagValue, optional, err = ParseTag(urlTagValue)
if err != nil {
return nil, err
}
foundValue := false
if parseCustomTags != nil {
foundValue, err = parseCustomTags(urlTagValue, r, field)
if err != nil {
return nil, err
}
}
if !foundValue {
err := DecodeURLTagValue(r, field, urlTagValue, optional)
if err != nil {
return nil, err
}
continue
}
}
_, jsonExpected := fp.Sf.Tag.Lookup("json")
if jsonExpected && nilBody {
return nil, &fleet.BadRequestError{Message: "Expected JSON Body"}
}
isContentJson := r.Header.Get("Content-Type") == "application/json"
isCrossSite := r.Header.Get("Origin") != "" || r.Header.Get("Referer") != ""
if jsonExpected && isCrossSite && !isContentJson {
return nil, fleet.NewUserMessageError(errors.New("Expected Content-Type \"application/json\""), http.StatusUnsupportedMediaType)
}
err = DecodeQueryTagValue(r, fp)
if err != nil {
return nil, err
}
}
if isBodyDecoder != nil && isBodyDecoder(v) {
err := decodeBody(ctx, r, v, body)
if err != nil {
return nil, err
}
}
if !license.IsPremium(ctx) {
for _, fp := range fields {
if prem, ok := fp.Sf.Tag.Lookup("premium"); ok {
val, err := strconv.ParseBool(prem)
if err != nil {
return nil, err
}
if val && !fp.V.IsZero() {
return nil, &fleet.BadRequestError{Message: fmt.Sprintf(
"option %s requires a premium license",
fp.Sf.Name,
)}
}
continue
}
}
}
if rv, ok := v.Interface().(requestValidator); ok {
if err := rv.ValidateRequest(); err != nil {
return nil, err
}
}
return v.Interface(), nil
}
}
func WriteBrowserSecurityHeaders(w http.ResponseWriter) {
// Strict-Transport-Security informs browsers that the site should only be
// accessed using HTTPS, and that any future attempts to access it using
// HTTP should automatically be converted to HTTPS.
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains;")
// X-Frames-Options disallows embedding the UI in other sites via <frame>,
// <iframe>, <embed> or <object>, which can prevent attacks like
// clickjacking.
w.Header().Set("X-Frame-Options", "SAMEORIGIN")
// X-Content-Type-Options prevents browsers from trying to guess the MIME
// type which can cause browsers to transform non-executable content into
// executable content.
w.Header().Set("X-Content-Type-Options", "nosniff")
// Referrer-Policy prevents leaking the origin of the referrer in the
// Referer.
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
}
type CommonEndpointer[H any] struct {
EP Endpointer[H]
MakeDecoderFn func(iface interface{}) kithttp.DecodeRequestFunc
EncodeFn kithttp.EncodeResponseFunc
Opts []kithttp.ServerOption
AuthMiddleware endpoint.Middleware
Router *mux.Router
Versions []string
// CustomMiddleware are middlewares that run before AuthMiddleware.
CustomMiddleware []endpoint.Middleware
// CustomMiddlewareAfterAuth are middlewares that run after AuthMiddleware.
CustomMiddlewareAfterAuth []endpoint.Middleware
startingAtVersion string
endingAtVersion string
alternativePaths []string
usePathPrefix bool
}
type Endpointer[H any] interface {
CallHandlerFunc(f H, ctx context.Context, request interface{}, svc interface{}) (fleet.Errorer, error)
Service() interface{}
}
func (e *CommonEndpointer[H]) POST(path string, f H, v interface{}) {
e.handleEndpoint(path, f, v, "POST")
}
func (e *CommonEndpointer[H]) GET(path string, f H, v interface{}) {
e.handleEndpoint(path, f, v, "GET")
}
func (e *CommonEndpointer[H]) PUT(path string, f H, v interface{}) {
e.handleEndpoint(path, f, v, "PUT")
}
func (e *CommonEndpointer[H]) PATCH(path string, f H, v interface{}) {
e.handleEndpoint(path, f, v, "PATCH")
}
func (e *CommonEndpointer[H]) DELETE(path string, f H, v interface{}) {
e.handleEndpoint(path, f, v, "DELETE")
}
func (e *CommonEndpointer[H]) HEAD(path string, f H, v interface{}) {
e.handleEndpoint(path, f, v, "HEAD")
}
func (e *CommonEndpointer[H]) handleEndpoint(path string, f H, v interface{}, verb string) {
endpoint := e.makeEndpoint(f, v)
e.HandleHTTPHandler(path, endpoint, verb)
}
func (e *CommonEndpointer[H]) makeEndpoint(f H, v interface{}) http.Handler {
next := func(ctx context.Context, request interface{}) (interface{}, error) {
return e.EP.CallHandlerFunc(f, ctx, request, e.EP.Service())
}
// Apply "after auth" middleware (in reverse order so that the first wraps
// the second wraps the third etc.)
endp := next
if len(e.CustomMiddlewareAfterAuth) > 0 {
for i := len(e.CustomMiddlewareAfterAuth) - 1; i >= 0; i-- {
mw := e.CustomMiddlewareAfterAuth[i]
endp = mw(endp)
}
}
if e.AuthMiddleware == nil {
// This panic catches potential security issues during development.
panic("AuthMiddleware must be set on CommonEndpointer")
}
endp = e.AuthMiddleware(endp)
// Apply "before auth" middleware (in reverse order so that the first wraps
// the second wraps the third etc.)
for i := len(e.CustomMiddleware) - 1; i >= 0; i-- {
mw := e.CustomMiddleware[i]
endp = mw(endp)
}
return newServer(endp, e.MakeDecoderFn(v), e.EncodeFn, e.Opts)
}
func newServer(e endpoint.Endpoint, decodeFn kithttp.DecodeRequestFunc, encodeFn kithttp.EncodeResponseFunc,
opts []kithttp.ServerOption,
) http.Handler {
// TODO: some handlers don't have authz checks, and because the SkipAuth call is done only in the
// endpoint handler, any middleware that raises errors before the handler is reached will end up
// returning authz check missing instead of the more relevant error. Should be addressed as part
// of #4406.
e = authzcheck.NewMiddleware().AuthzCheck()(e)
return kithttp.NewServer(e, decodeFn, encodeFn, opts...)
}
func (e *CommonEndpointer[H]) StartingAtVersion(version string) *CommonEndpointer[H] {
ae := *e
ae.startingAtVersion = version
return &ae
}
func (e *CommonEndpointer[H]) EndingAtVersion(version string) *CommonEndpointer[H] {
ae := *e
ae.endingAtVersion = version
return &ae
}
func (e *CommonEndpointer[H]) WithAltPaths(paths ...string) *CommonEndpointer[H] {
ae := *e
ae.alternativePaths = paths
return &ae
}
func (e *CommonEndpointer[H]) WithCustomMiddleware(mws ...endpoint.Middleware) *CommonEndpointer[H] {
ae := *e
ae.CustomMiddleware = mws
return &ae
}
func (e *CommonEndpointer[H]) AppendCustomMiddleware(mws ...endpoint.Middleware) *CommonEndpointer[H] {
ae := *e
ae.CustomMiddleware = append(ae.CustomMiddleware, mws...)
return &ae
}
func (e *CommonEndpointer[H]) WithCustomMiddlewareAfterAuth(mws ...endpoint.Middleware) *CommonEndpointer[H] {
ae := *e
ae.CustomMiddlewareAfterAuth = mws
return &ae
}
func (e *CommonEndpointer[H]) UsePathPrefix() *CommonEndpointer[H] {
ae := *e
ae.usePathPrefix = true
return &ae
}
// PathHandler registers a handler for the verb and path. The pathHandler is
// a function that receives the actual path to which it will be mounted, and
// returns the actual http.Handler that will handle this endpoint. This is for
// when the handler needs to know on which path it was called.
func (e *CommonEndpointer[H]) PathHandler(verb, path string, pathHandler func(path string) http.Handler) {
e.HandlePathHandler(path, pathHandler, verb)
}
func (e *CommonEndpointer[H]) HandleHTTPHandler(path string, h http.Handler, verb string) {
self := func(_ string) http.Handler { return h }
e.HandlePathHandler(path, self, verb)
}
var pathReplacer = strings.NewReplacer(
"/", "_",
"{", "_",
"}", "_",
)
func getNameFromPathAndVerb(verb, path, startAt string) string {
prefix := strings.ToLower(verb) + "_"
if startAt != "" {
prefix += pathReplacer.Replace(startAt) + "_"
}
return prefix + pathReplacer.Replace(strings.TrimPrefix(strings.TrimRight(path, "/"), "/api/_version_/fleet/"))
}
func (e *CommonEndpointer[H]) HandlePathHandler(path string, pathHandler func(path string) http.Handler, verb string) {
versions := e.Versions
if e.startingAtVersion != "" {
startIndex := -1
for i, version := range versions {
if version == e.startingAtVersion {
startIndex = i
break
}
}
if startIndex == -1 {
panic("StartAtVersion is not part of the valid versions")
}
versions = versions[startIndex:]
}
if e.endingAtVersion != "" {
endIndex := -1
for i, version := range versions {
if version == e.endingAtVersion {
endIndex = i
break
}
}
if endIndex == -1 {
panic("EndAtVersion is not part of the valid versions")
}
versions = versions[:endIndex+1]
}
// if a version doesn't have a deprecation version, or the ending version is the latest one, then it's part of the
// latest
if e.endingAtVersion == "" || e.endingAtVersion == e.Versions[len(e.Versions)-1] {
versions = append(versions, "latest")
}
versionedPath := strings.Replace(path, "/_version_/", fmt.Sprintf("/{fleetversion:(?:%s)}/", strings.Join(versions, "|")), 1)
nameAndVerb := getNameFromPathAndVerb(verb, path, e.startingAtVersion)
if e.usePathPrefix {
e.Router.PathPrefix(versionedPath).Handler(pathHandler(versionedPath)).Name(nameAndVerb).Methods(verb)
} else {
e.Router.Handle(versionedPath, pathHandler(versionedPath)).Name(nameAndVerb).Methods(verb)
}
for _, alias := range e.alternativePaths {
nameAndVerb := getNameFromPathAndVerb(verb, alias, e.startingAtVersion)
versionedPath := strings.Replace(alias, "/_version_/", fmt.Sprintf("/{fleetversion:(?:%s)}/", strings.Join(versions, "|")), 1)
if e.usePathPrefix {
e.Router.PathPrefix(versionedPath).Handler(pathHandler(versionedPath)).Name(nameAndVerb).Methods(verb)
} else {
e.Router.Handle(versionedPath, pathHandler(versionedPath)).Name(nameAndVerb).Methods(verb)
}
}
}
func EncodeCommonResponse(
ctx context.Context,
w http.ResponseWriter,
response interface{},
jsonMarshal func(w http.ResponseWriter, response interface{}) error,
) error {
if cs, ok := response.(cookieSetter); ok {
cs.SetCookies(ctx, w)
}
// The has to happen first, if an error happens we'll redirect to an error
// page and the error will be logged
if page, ok := response.(htmlPage); ok {
w.Header().Set("Content-Type", "text/html; charset=UTF-8")
WriteBrowserSecurityHeaders(w)
if coder, ok := page.Error().(kithttp.StatusCoder); ok {
w.WriteHeader(coder.StatusCode())
}
_, err := io.WriteString(w, page.Html())
return err
}
if e, ok := response.(fleet.Errorer); ok && e.Error() != nil {
EncodeError(ctx, e.Error(), w)
return nil
}
if render, ok := response.(renderHijacker); ok {
render.HijackRender(ctx, w)
return nil
}
if e, ok := response.(statuser); ok {
w.WriteHeader(e.Status())
if e.Status() == http.StatusNoContent {
return nil
}
}
return jsonMarshal(w, response)
}
// statuser allows response types to implement a custom
// http success status - default is 200 OK
type statuser interface {
Status() int
}
// loads a html page
type htmlPage interface {
Html() string
Error() error
}
// renderHijacker can be implemented by response values to take control of
// their own rendering.
type renderHijacker interface {
HijackRender(ctx context.Context, w http.ResponseWriter)
}
// cookieSetter can be implemented by response values to set cookies on the response.
type cookieSetter interface {
SetCookies(ctx context.Context, w http.ResponseWriter)
}