Explorar el Código

Merge branch 'api-users-context' of https://github.com/porter-dev/porter into frontend-boilerplate

jusrhee hace 5 años
padre
commit
f4805677ef

+ 1 - 1
cmd/app/main.go

@@ -34,7 +34,7 @@ func main() {
 
 	validator := vr.New()
 
-	a := api.New(logger, repo, validator, store, appConf.Server.CookieName)
+	a := api.New(logger, repo, validator, store, &appConf.Helm, appConf.Server.CookieName)
 
 	appRouter := router.New(a, store, appConf.Server.CookieName)
 

+ 2 - 0
go.mod

@@ -41,12 +41,14 @@ require (
 	gopkg.in/yaml.v2 v2.3.0
 	gorm.io/driver/postgres v1.0.2
 	gorm.io/gorm v1.20.2
+	helm.sh/helm v2.16.12+incompatible
 	helm.sh/helm/v3 v3.3.4
 	k8s.io/api v0.18.8
 	k8s.io/apimachinery v0.18.8
 	k8s.io/cli-runtime v0.18.8
 	k8s.io/client-go v0.18.8
 	k8s.io/gengo v0.0.0-20200413195148-3a45101e95ac // indirect
+	k8s.io/helm v2.16.12+incompatible // indirect
 	k8s.io/klog v1.0.0 // indirect
 	k8s.io/klog/v2 v2.2.0 // indirect
 	k8s.io/utils v0.0.0-20200912215256-4140de9c8800 // indirect

+ 4 - 0
go.sum

@@ -1263,6 +1263,7 @@ gorm.io/gorm v1.20.2/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
 gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
 helm.sh/helm v1.2.1 h1:Jrn7kKQqQ/hnFWZEX+9pMFvYqFexkzrBnGqYBmIph7c=
 helm.sh/helm v2.16.12+incompatible h1:nQfifk10KcpAGD1RJaNZVW/fWiqluV0JMuuDwdba4rw=
+helm.sh/helm v2.16.12+incompatible/go.mod h1:0Xbc6ErzwWH9qC55X1+hE3ZwhM3atbhCm/NbFZw5i+4=
 helm.sh/helm/v3 v3.3.4 h1:tbad6WQVMxEw1HlVBvI2rQqOblmI5lgXOrWAMwJ198M=
 helm.sh/helm/v3 v3.3.4/go.mod h1:CyCGQa53/k1JFxXvXveGwtfJ4cuB9zkaBSGa5rnAiHU=
 honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
@@ -1364,6 +1365,9 @@ k8s.io/component-base v0.18.8/go.mod h1:00frPRDas29rx58pPCxNkhUfPbwajlyyvu8ruNgS
 k8s.io/gengo v0.0.0-20190128074634-0689ccc1d7d6/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0=
 k8s.io/gengo v0.0.0-20200114144118-36b2048a9120/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0=
 k8s.io/gengo v0.0.0-20200413195148-3a45101e95ac/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0=
+k8s.io/helm v1.2.1 h1:Ny4wgW4p7X3tFXR34PziNkUxw2pV0G1DIFmI1QRDdo0=
+k8s.io/helm v2.16.12+incompatible h1:K2zhF8+B85Ya1n7n3eH34xwwp5qNUM42TBFENDZJT7w=
+k8s.io/helm v2.16.12+incompatible/go.mod h1:LZzlS4LQBHfciFOurYBFkCMTaZ0D1l+p0teMg7TSULI=
 k8s.io/klog v0.0.0-20181102134211-b9b56d5dfc92/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk=
 k8s.io/klog v0.3.0/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk=
 k8s.io/klog v1.0.0 h1:Pt+yjF5aB1xDSVbau4VsWe+dQNzA0qv1LlXdC2dF6Q8=

+ 6 - 0
internal/config/config.go

@@ -12,6 +12,7 @@ type Conf struct {
 	Debug  bool `env:"DEBUG,default=false"`
 	Server ServerConf
 	Db     DBConf
+	Helm   HelmGlobalConf
 }
 
 // ServerConf is the server configuration
@@ -34,6 +35,11 @@ type DBConf struct {
 	DbName   string `env:"DB_NAME,default=porter"`
 }
 
+// HelmGlobalConf is the global configuration for the Helm agent
+type HelmGlobalConf struct {
+	IsTesting bool `env:"HELM_IS_TESTING,default=false"`
+}
+
 // FromEnv generates a configuration from environment variables
 func FromEnv() *Conf {
 	var c Conf

+ 27 - 0
internal/forms/chart.go

@@ -0,0 +1,27 @@
+package forms
+
+import (
+	"github.com/porter-dev/porter/internal/helm"
+	"github.com/porter-dev/porter/internal/repository"
+)
+
+// ListChartForm represents the accepted values for listing Helm charts
+type ListChartForm struct {
+	HelmOptions *helm.Form       `json:"helm" form:"required"`
+	ListFilter  *helm.ListFilter `json:"filter" form:"required"`
+	UserID      uint             `json:"user_id"`
+}
+
+// PopulateHelmOptions uses the passed user ID to populate the HelmOptions object
+func (lcf *ListChartForm) PopulateHelmOptions(repo repository.UserRepository) error {
+	user, err := repo.ReadUser(lcf.UserID)
+
+	if err != nil {
+		return err
+	}
+
+	lcf.HelmOptions.AllowedContexts = user.ContextToSlice()
+
+	lcf.HelmOptions.KubeConfig = user.RawKubeConfig
+	return nil
+}

+ 6 - 2
internal/forms/user.go

@@ -1,6 +1,8 @@
 package forms
 
 import (
+	"strings"
+
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
@@ -85,7 +87,7 @@ func (uuf *UpdateUserForm) ToUser(repo repository.UserRepository) (*models.User,
 
 	// if the allowedContexts is nil, query the DB for a non-nil one
 	if uuf.AllowedContexts == nil {
-		contexts = savedUser.Contexts
+		contexts = savedUser.ContextToSlice()
 	}
 
 	if len(rawBytes) > 0 {
@@ -106,11 +108,13 @@ func (uuf *UpdateUserForm) ToUser(repo repository.UserRepository) (*models.User,
 		}
 	}
 
+	contextsJoin := strings.Join(contexts, ",")
+
 	return &models.User{
 		Model: gorm.Model{
 			ID: uuf.ID,
 		},
-		Contexts:      contexts,
+		Contexts:      contextsJoin,
 		RawKubeConfig: rawBytes,
 	}, nil
 }

+ 74 - 24
internal/helm/agent.go

@@ -1,51 +1,101 @@
 package helm
 
 import (
+	"io/ioutil"
+
+	"github.com/porter-dev/porter/internal/config"
+	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/logger"
 	"helm.sh/helm/v3/pkg/action"
 	"helm.sh/helm/v3/pkg/release"
-	"k8s.io/client-go/kubernetes"
-	"k8s.io/client-go/rest"
+	"helm.sh/helm/v3/pkg/storage"
+
+	"helm.sh/helm/v3/pkg/chartutil"
+	kubefake "helm.sh/helm/v3/pkg/kube/fake"
 )
 
 // Agent is a Helm agent for performing helm operations
-type Agent interface {
-	ListReleases(namespace string, filter *ListFilter) ([]*release.Release, error)
-	GetRelease(name string) (*release.Release, error)
+type Agent struct {
+	ActionConfig *action.Configuration
 }
 
-// DefaultAgent implements Agent using Helm's action.Configuration
-type DefaultAgent struct {
-	actionConfig *action.Configuration
+// Form represents the options for connecting to a cluster and
+// creating a Helm agent
+type Form struct {
+	KubeConfig      []byte
+	AllowedContexts []string
+	Context         string `json:"context" form:"required"`
+	Storage         string `json:"storage" form:"oneof=secret configmap memory"`
+	Namespace       string `json:"namespace"`
 }
 
-// NewDefaultAgent creates a new implementation of Agent
-func NewDefaultAgent(
+// ToAgent uses the Form to generate an agent. Setting testing=true will create
+// a test agent with in-memory storage
+func (h *Form) ToAgent(
 	l *logger.Logger,
-	storage string,
-	namespace string,
-	config *rest.Config,
-	clientset *kubernetes.Clientset,
-) (*DefaultAgent, error) {
-	// get the storage driver
-	storageDriver := StorageMap[storage]
+	helmConf *config.HelmGlobalConf,
+	storage *storage.Storage,
+) (*Agent, error) {
+	if helmConf.IsTesting {
+		testStorage := storage
+
+		if testStorage == nil {
+			testStorage = StorageMap["memory"](nil, h.Namespace, nil)
+		}
+
+		return &Agent{&action.Configuration{
+			Releases: testStorage,
+			KubeClient: &kubefake.FailingKubeClient{
+				PrintingKubeClient: kubefake.PrintingKubeClient{
+					Out: ioutil.Discard,
+				},
+			},
+			Capabilities: chartutil.DefaultCapabilities,
+			Log:          l.Printf,
+		}}, nil
+	}
+
+	// create a client config using the app's helm/kubernetes agents
+	conf, err := kubernetes.GetRestrictedClientConfigFromBytes(
+		h.KubeConfig,
+		h.Context,
+		h.AllowedContexts,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	restConf, err := conf.ClientConfig()
+
+	clientset, err := kubernetes.GetClientsetFromConfig(conf)
+
+	if err != nil {
+		return nil, err
+	}
 
-	// create the action config
-	actionConfig, err := NewActionConfig(l, storageDriver, config, clientset, namespace)
+	// create a new agent
+	actionConfig, err := NewActionConfig(
+		l,
+		StorageMap[h.Storage],
+		restConf,
+		clientset,
+		h.Namespace,
+	)
 
 	if err != nil {
 		return nil, err
 	}
 
-	return &DefaultAgent{actionConfig}, nil
+	return &Agent{actionConfig}, nil
 }
 
 // ListReleases lists releases based on a ListFilter
-func (a *DefaultAgent) ListReleases(
+func (a *Agent) ListReleases(
 	namespace string,
 	filter *ListFilter,
 ) ([]*release.Release, error) {
-	cmd := action.NewList(a.actionConfig)
+	cmd := action.NewList(a.ActionConfig)
 
 	filter.apply(cmd)
 
@@ -53,11 +103,11 @@ func (a *DefaultAgent) ListReleases(
 }
 
 // GetRelease returns the info of a release.
-func (a *DefaultAgent) GetRelease(
+func (a *Agent) GetRelease(
 	name string,
 ) (*release.Release, error) {
 	// Namespace is already known by the RESTClientGetter.
-	cmd := action.NewGet(a.actionConfig)
+	cmd := action.NewGet(a.ActionConfig)
 
 	return cmd.Run(name)
 }

+ 33 - 31
internal/helm/agent_test.go

@@ -1,36 +1,31 @@
 package helm_test
 
 import (
-	"io/ioutil"
 	"testing"
 
+	"helm.sh/helm/v3/pkg/storage/driver"
+
+	"github.com/porter-dev/porter/internal/config"
 	"github.com/porter-dev/porter/internal/helm"
 	"github.com/porter-dev/porter/internal/logger"
 
-	"helm.sh/helm/v3/pkg/action"
 	"helm.sh/helm/v3/pkg/chart"
-	"helm.sh/helm/v3/pkg/chartutil"
-	kubefake "helm.sh/helm/v3/pkg/kube/fake"
 	"helm.sh/helm/v3/pkg/release"
-	"helm.sh/helm/v3/pkg/storage"
-	"helm.sh/helm/v3/pkg/storage/driver"
 )
 
-func newActionConfigFixture(t *testing.T) *action.Configuration {
+func newAgentFixture(t *testing.T, namespace string) *helm.Agent {
 	t.Helper()
 
 	l := logger.NewConsole(true)
-
-	return &action.Configuration{
-		Releases: storage.Init(driver.NewMemory()),
-		KubeClient: &kubefake.FailingKubeClient{
-			PrintingKubeClient: kubefake.PrintingKubeClient{
-				Out: ioutil.Discard,
-			},
-		},
-		Capabilities: chartutil.DefaultCapabilities,
-		Log:          l.Printf,
+	opts := &helm.Form{
+		Namespace: namespace,
 	}
+
+	agent, _ := opts.ToAgent(l, &config.HelmGlobalConf{
+		IsTesting: true,
+	}, nil)
+
+	return agent
 }
 
 type releaseStub struct {
@@ -42,9 +37,10 @@ type releaseStub struct {
 }
 
 // makeReleases adds a slice of releases to the configured storage.
-func makeReleases(t *testing.T, actionConfig *action.Configuration, rels []releaseStub) {
+func makeReleases(t *testing.T, agent *helm.Agent, rels []releaseStub) {
 	t.Helper()
-	storage := actionConfig.Releases
+	storage := agent.ActionConfig.Releases
+
 	for _, r := range rels {
 		rel := &release.Release{
 			Name:      r.name,
@@ -60,7 +56,9 @@ func makeReleases(t *testing.T, actionConfig *action.Configuration, rels []relea
 				},
 			},
 		}
+
 		err := storage.Create(rel)
+
 		if err != nil {
 			t.Fatal(err)
 		}
@@ -157,17 +155,21 @@ var listReleaseTests = []listReleaseTest{
 	},
 }
 
-// func TestListReleases(t *testing.T) {
-// 	for _, tc := range listReleaseTests {
-// 		actionConfig := newActionConfigFixture(t)
-// 		makeReleases(t, actionConfig, tc.releases)
-// 		actionConfig.Releases.Driver.(*driver.Memory).SetNamespace(tc.namespace)
+func TestListReleases(t *testing.T) {
+	for _, tc := range listReleaseTests {
+		agent := newAgentFixture(t, tc.namespace)
+		makeReleases(t, agent, tc.releases)
+
+		// calling agent.ActionConfig.Releases.Create in makeReleases will automatically set the
+		// namespace, so we have to reset the namespace of the storage driver
+		agent.ActionConfig.Releases.Driver.(*driver.Memory).SetNamespace(tc.namespace)
+
+		releases, err := agent.ListReleases(tc.namespace, tc.filter)
 
-// 		releases, err := helm.ListReleases(actionConfig, tc.namespace, tc.filter)
-// 		if err != nil {
-// 			t.Errorf("%v", err)
-// 		}
+		if err != nil {
+			t.Errorf("%v", err)
+		}
 
-// 		compareReleaseToStubs(t, releases, tc.expRes)
-// 	}
-// }
+		compareReleaseToStubs(t, releases, tc.expRes)
+	}
+}

+ 2 - 2
internal/helm/driver.go

@@ -54,9 +54,9 @@ func newConfigMapsStorageDriver(
 
 // NewMemoryStorageDriver returns a storage using the In-Memory driver.
 func newMemoryStorageDriver(
-	l *logger.Logger,
+	_ *logger.Logger,
 	namespace string,
-	clientset *kubernetes.Clientset,
+	_ *kubernetes.Clientset,
 ) *storage.Storage {
 	d := driver.NewMemory()
 	return storage.Init(d)

+ 18 - 11
internal/models/user.go

@@ -1,6 +1,8 @@
 package models
 
 import (
+	"strings"
+
 	"gorm.io/gorm"
 )
 
@@ -8,10 +10,10 @@ import (
 type User struct {
 	gorm.Model
 
-	Email         string   `json:"email" gorm:"unique"`
-	Password      string   `json:"password"`
-	Contexts      []string `json:"contexts"`
-	RawKubeConfig []byte   `json:"rawKubeConfig"`
+	Email         string `json:"email" gorm:"unique"`
+	Password      string `json:"password"`
+	Contexts      string `json:"contexts"`
+	RawKubeConfig []byte `json:"rawKubeConfig"`
 }
 
 // UserExternal represents the User type that is sent over REST
@@ -24,16 +26,21 @@ type UserExternal struct {
 
 // Externalize generates an external User to be shared over REST
 func (u *User) Externalize() *UserExternal {
-	contexts := u.Contexts
-
-	if contexts == nil {
-		contexts = []string{}
-	}
-
 	return &UserExternal{
 		ID:            u.ID,
 		Email:         u.Email,
-		Contexts:      contexts,
+		Contexts:      u.ContextToSlice(),
 		RawKubeConfig: string(u.RawKubeConfig),
 	}
 }
+
+// ContextToSlice converts the serialized context string to an array of strings
+func (u *User) ContextToSlice() []string {
+	contexts := strings.Split(u.Contexts, ",")
+
+	if u.Contexts == "" {
+		contexts = make([]string, 0)
+	}
+
+	return contexts
+}

+ 1 - 1
internal/models/user_test.go

@@ -15,7 +15,7 @@ func TestUserExternalize(t *testing.T) {
 		},
 		Email:         "testing@testing.com",
 		Password:      "testing123",
-		Contexts:      []string{"test"},
+		Contexts:      "test",
 		RawKubeConfig: []byte{},
 	}
 

+ 9 - 1
server/api/api.go

@@ -5,8 +5,10 @@ import (
 	ut "github.com/go-playground/universal-translator"
 	"github.com/go-playground/validator/v10"
 	sessionstore "github.com/porter-dev/porter/internal/auth"
+	"github.com/porter-dev/porter/internal/config"
 	lr "github.com/porter-dev/porter/internal/logger"
 	"github.com/porter-dev/porter/internal/repository"
+	"helm.sh/helm/v3/pkg/storage"
 )
 
 // App represents an API instance with handler methods attached, a DB connection
@@ -17,7 +19,11 @@ type App struct {
 	validator  *validator.Validate
 	store      *sessionstore.PGStore
 	translator *ut.Translator
-	cookieName string
+	helmConf   *config.HelmGlobalConf
+	// HelmTestStorageDriver is used by testing libraries to query the in-memory
+	// Helm storage driver
+	HelmTestStorageDriver *storage.Storage
+	cookieName            string
 }
 
 // New returns a new App instance
@@ -26,6 +32,7 @@ func New(
 	repo *repository.Repository,
 	validator *validator.Validate,
 	store *sessionstore.PGStore,
+	helmConf *config.HelmGlobalConf,
 	cookieName string,
 ) *App {
 	// for now, will just support the english translator from the
@@ -40,6 +47,7 @@ func New(
 		validator:  validator,
 		store:      store,
 		translator: &trans,
+		helmConf:   helmConf,
 		cookieName: cookieName,
 	}
 }

+ 42 - 5
server/api/chart_handler.go

@@ -1,12 +1,49 @@
 package api
 
-import "net/http"
+import (
+	"encoding/json"
+	"net/http"
 
-// TODO -- IMPLEMENT
+	"github.com/porter-dev/porter/internal/forms"
+)
+
+// Enumeration of chart API error codes, represented as int64
+const (
+	ErrChartDecode ErrorCode = iota + 600
+	ErrChartValidateFields
+)
+
+// HandleListCharts retrieves a list of charts with various filter options
 func (app *App) HandleListCharts(w http.ResponseWriter, r *http.Request) {
-	// get the user id
+	// get the filter options
+	form := &forms.ListChartForm{}
+
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrChartDecode, w)
+		return
+	}
+
+	form.PopulateHelmOptions(app.repo.User)
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrChartValidateFields, w)
+		return
+	}
+
+	// create a new agent
+	agent, err := form.HelmOptions.ToAgent(app.logger, app.helmConf, app.HelmTestStorageDriver)
+
+	releases, err := agent.ListReleases(form.HelmOptions.Namespace, form.ListFilter)
 
-	// create a client config using the app's helm/kubernetes agents
+	if err != nil {
+		app.handleErrorFormValidation(err, ErrChartValidateFields, w)
+		return
+	}
 
-	// call the list charts method
+	if err := json.NewEncoder(w).Encode(releases); err != nil {
+		app.handleErrorFormDecoding(err, ErrChartDecode, w)
+		return
+	}
 }

+ 230 - 0
server/api/chart_handler_test.go

@@ -0,0 +1,230 @@
+package api_test
+
+import (
+	"encoding/json"
+	"net/http"
+	"reflect"
+	"strings"
+	"testing"
+
+	"github.com/porter-dev/porter/internal/config"
+	"github.com/porter-dev/porter/internal/helm"
+	"github.com/porter-dev/porter/internal/logger"
+
+	"helm.sh/helm/v3/pkg/chart"
+	"helm.sh/helm/v3/pkg/release"
+	"helm.sh/helm/v3/pkg/storage"
+	"helm.sh/helm/v3/pkg/storage/driver"
+)
+
+type releaseStub struct {
+	name         string
+	namespace    string
+	version      int
+	chartVersion string
+	status       release.Status
+}
+
+// type ListFilter struct {
+// 	Namespace    string   `json:"namespace"`
+// 	Limit        int      `json:"limit"`
+// 	Skip         int      `json:"skip"`
+// 	ByDate       bool     `json:"byDate"`
+// 	StatusFilter []string `json:"statusFilter"`
+// }
+
+// type Form struct {
+// 	KubeConfig      []byte   `form:"required"`
+// 	AllowedContexts []string `form:"required"`
+// 	Context         string   `json:"context" form:"required"`
+// 	Storage         string   `json:"storage" form:"oneof=secret configmap memory"`
+// 	Namespace       string   `json:"namespace"`
+// }
+
+// type ListChartForm struct {
+// 	HelmOptions *helm.Form       `json:"helm" form:"required"`
+// 	ListFilter  *helm.ListFilter `json:"filter" form:"required"`
+// 	UserID      uint             `json:"user_id"`
+// }
+
+// ------------------------- TEST TYPES AND MAIN LOOP ------------------------- //
+
+type chartTest struct {
+	initializers []func(tester *tester)
+	msg          string
+	method       string
+	endpoint     string
+	body         string
+	expStatus    int
+	expBody      string
+	useCookie    bool
+	validators   []func(c *chartTest, tester *tester, t *testing.T)
+}
+
+func testChartRequests(t *testing.T, tests []*chartTest, canQuery bool) {
+	for _, c := range tests {
+		// create a new tester
+		storage := helm.StorageMap["memory"](nil, "", nil)
+		tester := newTester(canQuery, storage)
+
+		// if there's an initializer, call it
+		for _, init := range c.initializers {
+			init(tester)
+		}
+
+		req, err := http.NewRequest(
+			c.method,
+			c.endpoint,
+			strings.NewReader(c.body),
+		)
+
+		tester.req = req
+
+		if c.useCookie {
+			req.AddCookie(tester.cookie)
+		}
+
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		tester.execute()
+		rr := tester.rr
+
+		// first, check that the status matches
+		if status := rr.Code; status != c.expStatus {
+			t.Errorf("%s, handler returned wrong status code: got %v want %v",
+				c.msg, status, c.expStatus)
+		}
+
+		// if there's a validator, call it
+		for _, validate := range c.validators {
+			validate(c, tester, t)
+		}
+	}
+}
+
+// ------------------------- TEST FIXTURES AND FUNCTIONS  ------------------------- //
+
+var listChartsTests = []*chartTest{
+	&chartTest{
+		initializers: []func(tester *tester){
+			initDefaultCharts,
+		},
+		msg:      "List charts",
+		method:   "GET",
+		endpoint: "/api/charts",
+		body: `{
+			"user_id": 1,
+			"helm": {
+				"namespace": "",
+				"context": "context-test",
+				"storage": "memory"
+			},
+			"filter": {
+				"namespace": "",
+				"limit": 20,
+				"skip": 0,
+				"byDate": false,
+				"statusFilter": ["deployed"]
+			}
+		}`,
+		expStatus: http.StatusOK,
+		expBody:   releaseStubsToChartJSON(sampleReleaseStubs),
+		useCookie: true,
+		validators: []func(c *chartTest, tester *tester, t *testing.T){
+			chartReleaseBodyValidator,
+		},
+	},
+}
+
+func TestHandleListCharts(t *testing.T) {
+	testChartRequests(t, listChartsTests, true)
+}
+
+// ------------------------- INITIALIZERS AND VALIDATORS ------------------------- //
+
+func initDefaultCharts(tester *tester) {
+	initUserDefault(tester)
+
+	agent := newAgentFixture("default", tester.app.HelmTestStorageDriver)
+
+	makeReleases(agent, sampleReleaseStubs)
+
+	// calling agent.ActionConfig.Releases.Create in makeReleases will automatically set the
+	// namespace, so we have to reset the namespace of the storage driver
+	agent.ActionConfig.Releases.Driver.(*driver.Memory).SetNamespace("")
+}
+
+func newAgentFixture(namespace string, storage *storage.Storage) *helm.Agent {
+	l := logger.NewConsole(true)
+	opts := &helm.Form{
+		Namespace: namespace,
+	}
+
+	agent, _ := opts.ToAgent(l, &config.HelmGlobalConf{
+		IsTesting: true,
+	}, storage)
+
+	return agent
+}
+
+var sampleReleaseStubs = []releaseStub{
+	releaseStub{"airwatch", "default", 1, "1.0.0", release.StatusDeployed},
+	releaseStub{"not-in-default-namespace", "other", 1, "1.0.1", release.StatusDeployed},
+	releaseStub{"wordpress", "default", 1, "1.0.2", release.StatusDeployed},
+}
+
+func releaseStubsToChartJSON(rels []releaseStub) string {
+	releases := make([]*release.Release, 0)
+
+	for _, r := range rels {
+		rel := releaseStubToRelease(r)
+
+		releases = append(releases, rel)
+	}
+
+	str, _ := json.Marshal(releases)
+
+	return string(str)
+}
+
+func releaseStubToRelease(r releaseStub) *release.Release {
+	return &release.Release{
+		Name:      r.name,
+		Namespace: r.namespace,
+		Version:   r.version,
+		Info: &release.Info{
+			Status: r.status,
+		},
+		Chart: &chart.Chart{
+			Metadata: &chart.Metadata{
+				Version: r.chartVersion,
+				Icon:    "https://example.com/icon.png",
+			},
+		},
+	}
+}
+
+func makeReleases(agent *helm.Agent, rels []releaseStub) {
+	storage := agent.ActionConfig.Releases
+
+	for _, r := range rels {
+		rel := releaseStubToRelease(r)
+
+		storage.Create(rel)
+	}
+}
+
+func chartReleaseBodyValidator(c *chartTest, tester *tester, t *testing.T) {
+	gotBody := &[]release.Release{}
+	expBody := &[]release.Release{}
+
+	json.Unmarshal(tester.rr.Body.Bytes(), gotBody)
+	json.Unmarshal([]byte(c.expBody), expBody)
+
+	if !reflect.DeepEqual(gotBody, expBody) {
+		t.Errorf("%s, handler returned wrong body: got %v want %v",
+			c.msg, gotBody, expBody)
+	}
+}

+ 96 - 0
server/api/helpers_test.go

@@ -0,0 +1,96 @@
+package api_test
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"time"
+
+	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/internal/config"
+	lr "github.com/porter-dev/porter/internal/logger"
+	"github.com/porter-dev/porter/internal/repository"
+	"github.com/porter-dev/porter/internal/repository/test"
+	"github.com/porter-dev/porter/server/api"
+	"github.com/porter-dev/porter/server/router"
+	"helm.sh/helm/v3/pkg/storage"
+
+	sessionstore "github.com/porter-dev/porter/internal/auth"
+	vr "github.com/porter-dev/porter/internal/validator"
+)
+
+type tester struct {
+	app    *api.App
+	repo   *repository.Repository
+	store  *sessionstore.PGStore
+	router *chi.Mux
+	req    *http.Request
+	rr     *httptest.ResponseRecorder
+	cookie *http.Cookie
+}
+
+func (t *tester) execute() {
+	t.router.ServeHTTP(t.rr, t.req)
+}
+
+func (t *tester) reset() {
+	t.rr = httptest.NewRecorder()
+	t.req = nil
+}
+
+func (t *tester) createUserSession(email string, pw string) {
+	req, _ := http.NewRequest(
+		"POST",
+		"/api/users",
+		strings.NewReader(`{"email":"`+email+`","password":"`+pw+`"}`),
+	)
+
+	t.req = req
+	t.execute()
+
+	if cookies := t.rr.Result().Cookies(); len(cookies) > 0 {
+		t.cookie = cookies[0]
+	}
+
+	t.reset()
+}
+
+func newTester(canQuery bool, storage *storage.Storage) *tester {
+	appConf := config.Conf{
+		Debug: true,
+		Server: config.ServerConf{
+			Port:         8080,
+			CookieName:   "porter",
+			CookieSecret: []byte("secret"),
+			TimeoutRead:  time.Second * 5,
+			TimeoutWrite: time.Second * 10,
+			TimeoutIdle:  time.Second * 15,
+		},
+		// unimportant here
+		Db: config.DBConf{},
+		// set the helm config to testing
+		Helm: config.HelmGlobalConf{
+			IsTesting: true,
+		},
+	}
+
+	logger := lr.NewConsole(appConf.Debug)
+	validator := vr.New()
+
+	repo := test.NewRepository(canQuery)
+
+	store, _ := sessionstore.NewStore(repo, appConf.Server)
+	app := api.New(logger, repo, validator, store, &appConf.Helm, appConf.Server.CookieName)
+	app.HelmTestStorageDriver = storage
+	r := router.New(app, store, appConf.Server.CookieName)
+
+	return &tester{
+		app:    app,
+		repo:   repo,
+		store:  store,
+		router: r,
+		req:    nil,
+		rr:     httptest.NewRecorder(),
+		cookie: nil,
+	}
+}

+ 1 - 1
server/api/user_handler.go

@@ -162,7 +162,7 @@ func (app *App) HandleReadUserContexts(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	contexts, err := kubernetes.GetContextsFromBytes(user.RawKubeConfig, user.Contexts)
+	contexts, err := kubernetes.GetContextsFromBytes(user.RawKubeConfig, user.ContextToSlice())
 
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrUserDecode, w)

+ 43 - 116
server/api/user_handler_test.go

@@ -2,35 +2,17 @@ package api_test
 
 import (
 	"encoding/json"
+	"fmt"
 	"net/http"
 	"net/http/httptest"
 	"reflect"
 	"strings"
 	"testing"
-	"time"
 
-	"github.com/go-chi/chi"
-	"github.com/porter-dev/porter/internal/config"
 	"github.com/porter-dev/porter/internal/models"
-	"github.com/porter-dev/porter/internal/repository"
-	"github.com/porter-dev/porter/internal/repository/test"
-	"github.com/porter-dev/porter/server/api"
-	"github.com/porter-dev/porter/server/router"
-
-	sessionstore "github.com/porter-dev/porter/internal/auth"
-	lr "github.com/porter-dev/porter/internal/logger"
-	vr "github.com/porter-dev/porter/internal/validator"
 )
 
-type tester struct {
-	app    *api.App
-	repo   *repository.Repository
-	store  *sessionstore.PGStore
-	router *chi.Mux
-	req    *http.Request
-	rr     *httptest.ResponseRecorder
-	cookie *http.Cookie
-}
+// ------------------------- TEST TYPES AND MAIN LOOP ------------------------- //
 
 type userTest struct {
 	initializers []func(t *tester)
@@ -44,86 +26,10 @@ type userTest struct {
 	validators   []func(c *userTest, tester *tester, t *testing.T)
 }
 
-func (t *tester) execute() {
-	t.router.ServeHTTP(t.rr, t.req)
-}
-
-func (t *tester) reset() {
-	t.rr = httptest.NewRecorder()
-	t.req = nil
-}
-
-func (t *tester) createUserSession(email string, pw string) {
-	req, _ := http.NewRequest(
-		"POST",
-		"/api/users",
-		strings.NewReader(`{"email":"`+email+`","password":"`+pw+`"}`),
-	)
-
-	t.req = req
-	t.execute()
-
-	if cookies := t.rr.Result().Cookies(); len(cookies) > 0 {
-		t.cookie = cookies[0]
-	}
-
-	t.reset()
-}
-
-func initUserDefault(tester *tester) {
-	tester.createUserSession("belanger@getporter.dev", "hello")
-}
-
-func initUserWithContexts(tester *tester) {
-	initUserDefault(tester)
-
-	user, _ := tester.repo.User.ReadUserByEmail("belanger@getporter.dev")
-	user.Contexts = []string{"context-test"}
-
-	user.RawKubeConfig = []byte("apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: context-test\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin")
-
-	tester.repo.User.UpdateUser(user)
-}
-
-func newTester(canQuery bool) *tester {
-	appConf := config.Conf{
-		Debug: true,
-		Server: config.ServerConf{
-			Port:         8080,
-			CookieName:   "porter",
-			CookieSecret: []byte("secret"),
-			TimeoutRead:  time.Second * 5,
-			TimeoutWrite: time.Second * 10,
-			TimeoutIdle:  time.Second * 15,
-		},
-		// unimportant here
-		Db: config.DBConf{},
-	}
-
-	logger := lr.NewConsole(appConf.Debug)
-	validator := vr.New()
-
-	repo := test.NewRepository(canQuery)
-
-	store, _ := sessionstore.NewStore(repo, appConf.Server)
-	app := api.New(logger, repo, validator, store, appConf.Server.CookieName)
-	r := router.New(app, store, appConf.Server.CookieName)
-
-	return &tester{
-		app:    app,
-		repo:   repo,
-		store:  store,
-		router: r,
-		req:    nil,
-		rr:     httptest.NewRecorder(),
-		cookie: nil,
-	}
-}
-
 func testUserRequests(t *testing.T, tests []*userTest, canQuery bool) {
 	for _, c := range tests {
 		// create a new tester
-		tester := newTester(canQuery)
+		tester := newTester(canQuery, nil)
 
 		// if there's an initializer, call it
 		for _, init := range c.initializers {
@@ -162,6 +68,8 @@ func testUserRequests(t *testing.T, tests []*userTest, canQuery bool) {
 	}
 }
 
+// ------------------------- TEST FIXTURES AND FUNCTIONS  ------------------------- //
+
 var createUserTests = []*userTest{
 	&userTest{
 		msg:      "Create user",
@@ -185,7 +93,7 @@ var createUserTests = []*userTest{
 		expStatus: http.StatusUnprocessableEntity,
 		expBody:   `{"code":601,"errors":["email validation failed"]}`,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			BasicBodyValidator,
+			userBasicBodyValidator,
 		},
 	},
 	&userTest{
@@ -198,7 +106,7 @@ var createUserTests = []*userTest{
 		expStatus: http.StatusUnprocessableEntity,
 		expBody:   `{"code":601,"errors":["required validation failed"]}`,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			BasicBodyValidator,
+			userBasicBodyValidator,
 		},
 	},
 	&userTest{
@@ -215,7 +123,7 @@ var createUserTests = []*userTest{
 		expStatus: http.StatusUnprocessableEntity,
 		expBody:   `{"code":601,"errors":["email already taken"]}`,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			BasicBodyValidator,
+			userBasicBodyValidator,
 		},
 	},
 	&userTest{
@@ -229,7 +137,7 @@ var createUserTests = []*userTest{
 		expStatus: http.StatusBadRequest,
 		expBody:   `{"code":600,"errors":["could not process request"]}`,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			BasicBodyValidator,
+			userBasicBodyValidator,
 		},
 	},
 }
@@ -250,7 +158,7 @@ var createUserTestsWriteFail = []*userTest{
 		expStatus: http.StatusInternalServerError,
 		expBody:   `{"code":500,"errors":["could not read from database"]}`,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			BasicBodyValidator,
+			userBasicBodyValidator,
 		},
 	},
 }
@@ -274,7 +182,7 @@ var loginUserTests = []*userTest{
 		expStatus: http.StatusOK,
 		expBody:   ``,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			BasicBodyValidator,
+			userBasicBodyValidator,
 		},
 	},
 	&userTest{
@@ -292,7 +200,7 @@ var loginUserTests = []*userTest{
 		expBody:   ``,
 		useCookie: true,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			BasicBodyValidator,
+			userBasicBodyValidator,
 		},
 	},
 	&userTest{
@@ -306,7 +214,7 @@ var loginUserTests = []*userTest{
 		expStatus: http.StatusUnauthorized,
 		expBody:   `{"code":401,"errors":["email not registered"]}`,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			BasicBodyValidator,
+			userBasicBodyValidator,
 		},
 	},
 	&userTest{
@@ -324,7 +232,7 @@ var loginUserTests = []*userTest{
 		expBody:   `{"code":401,"errors":["incorrect password"]}`,
 		useCookie: true,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			BasicBodyValidator,
+			userBasicBodyValidator,
 		},
 	},
 }
@@ -391,7 +299,7 @@ var readUserTests = []*userTest{
 		expBody:   `{"id":1,"email":"belanger@getporter.dev","contexts":["context-test"],"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: context-test\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin"}`,
 		useCookie: true,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			UserModelBodyValidator,
+			userModelBodyValidator,
 		},
 	},
 	&userTest{
@@ -405,7 +313,7 @@ var readUserTests = []*userTest{
 		expStatus: http.StatusForbidden,
 		expBody:   http.StatusText(http.StatusForbidden) + "\n",
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			BasicBodyValidator,
+			userBasicBodyValidator,
 		},
 	},
 }
@@ -427,7 +335,7 @@ var readUserClustersTests = []*userTest{
 		useCookie: true,
 		expBody:   `[{"name":"context-test","server":"https://localhost","cluster":"cluster-test","user":"test-admin","selected":true}]`,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			ContextBodyValidator,
+			userContextBodyValidator,
 		},
 	},
 }
@@ -471,6 +379,8 @@ var updateUserTests = []*userTest{
 				json.Unmarshal(rr2.Body.Bytes(), gotBody)
 				json.Unmarshal([]byte(`{"id":1,"email":"belanger@getporter.dev","contexts":[],"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: context-test\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin"}`), expBody)
 
+				fmt.Println(rr2.Body.String())
+
 				if !reflect.DeepEqual(gotBody, expBody) {
 					t.Errorf("%s, handler returned wrong body: got %v want %v",
 						"validator failed", gotBody, expBody)
@@ -612,7 +522,7 @@ var updateUserTests = []*userTest{
 		expStatus: http.StatusForbidden,
 		expBody:   http.StatusText(http.StatusForbidden) + "\n",
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			BasicBodyValidator,
+			userBasicBodyValidator,
 		},
 	},
 	&userTest{
@@ -627,7 +537,7 @@ var updateUserTests = []*userTest{
 		expBody:   `{"code":600,"errors":["could not process request"]}`,
 		useCookie: true,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			BasicBodyValidator,
+			userBasicBodyValidator,
 		},
 	},
 }
@@ -695,7 +605,7 @@ var deleteUserTests = []*userTest{
 		expStatus: http.StatusForbidden,
 		expBody:   http.StatusText(http.StatusForbidden) + "\n",
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			BasicBodyValidator,
+			userBasicBodyValidator,
 		},
 	},
 	&userTest{
@@ -710,7 +620,7 @@ var deleteUserTests = []*userTest{
 		expBody:   `{"code":601,"errors":["required validation failed"]}`,
 		useCookie: true,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			BasicBodyValidator,
+			userBasicBodyValidator,
 		},
 	},
 }
@@ -719,14 +629,31 @@ func TestHandleDeleteUser(t *testing.T) {
 	testUserRequests(t, deleteUserTests, true)
 }
 
-func BasicBodyValidator(c *userTest, tester *tester, t *testing.T) {
+// ------------------------- INITIALIZERS AND VALIDATORS ------------------------- //
+
+func initUserDefault(tester *tester) {
+	tester.createUserSession("belanger@getporter.dev", "hello")
+}
+
+func initUserWithContexts(tester *tester) {
+	initUserDefault(tester)
+
+	user, _ := tester.repo.User.ReadUserByEmail("belanger@getporter.dev")
+	user.Contexts = "context-test"
+
+	user.RawKubeConfig = []byte("apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: context-test\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin")
+
+	tester.repo.User.UpdateUser(user)
+}
+
+func userBasicBodyValidator(c *userTest, tester *tester, t *testing.T) {
 	if body := tester.rr.Body.String(); body != c.expBody {
 		t.Errorf("%s, handler returned wrong body: got %v want %v",
 			c.msg, body, c.expBody)
 	}
 }
 
-func UserModelBodyValidator(c *userTest, tester *tester, t *testing.T) {
+func userModelBodyValidator(c *userTest, tester *tester, t *testing.T) {
 	gotBody := &models.UserExternal{}
 	expBody := &models.UserExternal{}
 
@@ -739,7 +666,7 @@ func UserModelBodyValidator(c *userTest, tester *tester, t *testing.T) {
 	}
 }
 
-func ContextBodyValidator(c *userTest, tester *tester, t *testing.T) {
+func userContextBodyValidator(c *userTest, tester *tester, t *testing.T) {
 	gotBody := &[]models.Context{}
 	expBody := &[]models.Context{}
 

+ 34 - 2
server/router/middleware/auth.go

@@ -1,6 +1,9 @@
 package middleware
 
 import (
+	"bytes"
+	"encoding/json"
+	"io/ioutil"
 	"net/http"
 	"strconv"
 
@@ -8,11 +11,13 @@ import (
 	sessionstore "github.com/porter-dev/porter/internal/auth"
 )
 
+// Auth implements the authorization functions
 type Auth struct {
 	store      *sessionstore.PGStore
 	cookieName string
 }
 
+// NewAuth returns a new Auth instance
 func NewAuth(
 	store *sessionstore.PGStore,
 	cookieName string,
@@ -34,11 +39,38 @@ func (auth *Auth) BasicAuthenticate(next http.Handler) http.Handler {
 	})
 }
 
+// IDLocation represents the location of the ID to use for authentication
+type IDLocation uint
+
+const (
+	// URLParam location looks for {id} in the URL
+	URLParam IDLocation = iota
+	// BodyParam location looks for user_id in the body
+	BodyParam
+)
+
+type bodyID struct {
+	UserID uint64 `json:"user_id"`
+}
+
 // DoesUserIDMatch checks the id URL parameter and verifies that it matches
 // the one stored in the session
-func (auth *Auth) DoesUserIDMatch(next http.Handler) http.Handler {
+func (auth *Auth) DoesUserIDMatch(next http.Handler, loc IDLocation) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		id, err := strconv.ParseUint(chi.URLParam(r, "id"), 0, 64)
+		var id uint64
+		var err error
+
+		if loc == URLParam {
+			id, err = strconv.ParseUint(chi.URLParam(r, "id"), 0, 64)
+		} else if loc == BodyParam {
+			form := &bodyID{}
+			body, _ := ioutil.ReadAll(r.Body)
+			err = json.Unmarshal(body, form)
+			id = form.UserID
+
+			// need to create a new stream for the body
+			r.Body = ioutil.NopCloser(bytes.NewReader(body))
+		}
 
 		if err == nil && auth.doesSessionMatchID(r, uint(id)) {
 			next.ServeHTTP(w, r)

+ 10 - 7
server/router/router.go

@@ -4,7 +4,7 @@ import (
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/server/api"
 	"github.com/porter-dev/porter/server/requestlog"
-	"github.com/porter-dev/porter/server/router/middleware"
+	mw "github.com/porter-dev/porter/server/router/middleware"
 
 	sessionstore "github.com/porter-dev/porter/internal/auth"
 )
@@ -13,20 +13,23 @@ import (
 func New(a *api.App, store *sessionstore.PGStore, cookieName string) *chi.Mux {
 	l := a.Logger()
 	r := chi.NewRouter()
-	auth := middleware.NewAuth(store, cookieName)
+	auth := mw.NewAuth(store, cookieName)
 
 	r.Route("/api", func(r chi.Router) {
-		r.Use(middleware.ContentTypeJSON)
+		r.Use(mw.ContentTypeJSON)
 
 		// /api/users routes
-		r.Method("GET", "/users/{id}", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleReadUser, l)))
-		r.Method("GET", "/users/{id}/contexts", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleReadUserContexts, l)))
+		r.Method("GET", "/users/{id}", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleReadUser, l), mw.URLParam))
+		r.Method("GET", "/users/{id}/contexts", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleReadUserContexts, l), mw.URLParam))
 		r.Method("POST", "/users", requestlog.NewHandler(a.HandleCreateUser, l))
-		r.Method("PUT", "/users/{id}", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleUpdateUser, l)))
-		r.Method("DELETE", "/users/{id}", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleDeleteUser, l)))
+		r.Method("PUT", "/users/{id}", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleUpdateUser, l), mw.URLParam))
+		r.Method("DELETE", "/users/{id}", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleDeleteUser, l), mw.URLParam))
 		r.Method("POST", "/login", requestlog.NewHandler(a.HandleLoginUser, l))
 		r.Method("GET", "/auth/check", requestlog.NewHandler(a.HandleAuthCheck, l))
 		r.Method("POST", "/logout", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleLogoutUser, l)))
+
+		// /api/charts routes
+		r.Method("GET", "/charts", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleListCharts, l), mw.BodyParam))
 	})
 
 	return r