Skip to content

Commit

Permalink
feat: list resources endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
JadhavPoonam committed Aug 14, 2023
1 parent cda884a commit 9ddc720
Show file tree
Hide file tree
Showing 2 changed files with 187 additions and 14 deletions.
92 changes: 78 additions & 14 deletions internal/resource/http/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,24 @@ import (
"github.com/hashicorp/consul/proto-public/pbresource"
)

const (
HeaderConsulToken = "x-consul-token"
HeaderConsistencyMode = "x-consul-consistency-mode"
)

func NewHandler(
client pbresource.ResourceServiceClient,
registry resource.Registry,
parseToken func(req *http.Request, token *string),
logger hclog.Logger) http.Handler {
mux := http.NewServeMux()
for _, t := range registry.Types() {
// Individual Resource Endpoints.
prefix := strings.ToLower(fmt.Sprintf("/%s/%s/%s/", t.Type.Group, t.Type.GroupVersion, t.Type.Kind))
// List Endpoint
base := strings.ToLower(fmt.Sprintf("/%s/%s/%s", t.Type.Group, t.Type.GroupVersion, t.Type.Kind))
mux.Handle(base, http.StripPrefix(base, &listHandler{t, client, parseToken, logger}))

// Individual Resource Endpoints
prefix := strings.ToLower(fmt.Sprintf("%s/", base))
logger.Info("Registered resource endpoint", "endpoint", prefix)
mux.Handle(prefix, http.StripPrefix(prefix, &resourceHandler{t, client, parseToken, logger}))
}
Expand All @@ -55,7 +64,7 @@ type resourceHandler struct {
func (h *resourceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
var token string
h.parseToken(r, &token)
ctx := metadata.AppendToOutgoingContext(r.Context(), "x-consul-token", token)
ctx := metadata.AppendToOutgoingContext(r.Context(), HeaderConsulToken, token)
switch r.Method {
case http.MethodPut:
h.handleWrite(w, r, ctx)
Expand Down Expand Up @@ -106,7 +115,7 @@ func (h *resourceHandler) handleWrite(w http.ResponseWriter, r *http.Request, ct
},
})
if err != nil {
handleResponseError(err, w, h)
handleResponseError(err, w, h.logger)
return
}

Expand All @@ -133,7 +142,7 @@ func (h *resourceHandler) handleRead(w http.ResponseWriter, r *http.Request, ctx
},
})
if err != nil {
handleResponseError(err, w, h)
handleResponseError(err, w, h.logger)
return
}

Expand All @@ -158,7 +167,7 @@ func (h *resourceHandler) handleDelete(w http.ResponseWriter, r *http.Request, c
Version: params["version"],
})
if err != nil {
handleResponseError(err, w, h)
handleResponseError(err, w, h.logger)
return
}
w.WriteHeader(http.StatusNoContent)
Expand All @@ -181,11 +190,12 @@ func parseParams(r *http.Request) (tenancy *pbresource.Tenancy, params map[strin
params = make(map[string]string)
params["resourceName"] = resourceName
params["version"] = query.Get("version")
params["namePrefix"] = query.Get("name_prefix")
if _, ok := query["consistent"]; ok {
params["consistent"] = "true"
}

return
return tenancy, params
}

func jsonMarshal(res *pbresource.Resource) ([]byte, error) {
Expand All @@ -203,28 +213,82 @@ func jsonMarshal(res *pbresource.Resource) ([]byte, error) {
return json.MarshalIndent(stuff, "", " ")
}

func handleResponseError(err error, w http.ResponseWriter, h *resourceHandler) {
func handleResponseError(err error, w http.ResponseWriter, logger hclog.Logger) {
if e, ok := status.FromError(err); ok {
switch e.Code() {
case codes.InvalidArgument:
w.WriteHeader(http.StatusBadRequest)
h.logger.Info("User has mal-formed request", "error", err)
logger.Info("User has mal-formed request", "error", err)
case codes.NotFound:
w.WriteHeader(http.StatusNotFound)
h.logger.Info("Received error from resource service: Not found", "error", err)
logger.Info("Received error from resource service: Not found", "error", err)
case codes.PermissionDenied:
w.WriteHeader(http.StatusForbidden)
h.logger.Info("Received error from resource service: User not authenticated", "error", err)
logger.Info("Received error from resource service: User not authenticated", "error", err)
case codes.Aborted:
w.WriteHeader(http.StatusConflict)
h.logger.Info("Received error from resource service: the request conflict with the current state of the target resource", "error", err)
logger.Info("Received error from resource service: the request conflict with the current state of the target resource", "error", err)
default:
w.WriteHeader(http.StatusInternalServerError)
h.logger.Error("Received error from resource service", "error", err)
logger.Error("Received error from resource service", "error", err)
}
} else {
w.WriteHeader(http.StatusInternalServerError)
h.logger.Error("Received error from resource service: not able to parse error returned", "error", err)
logger.Error("Received error from resource service: not able to parse error returned", "error", err)
}
w.Write([]byte(err.Error()))
}

type listHandler struct {
reg resource.Registration
client pbresource.ResourceServiceClient
parseToken func(req *http.Request, token *string)
logger hclog.Logger
}

func (h *listHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}

var token string
h.parseToken(r, &token)
ctx := metadata.AppendToOutgoingContext(r.Context(), HeaderConsulToken, token)

tenancyInfo, params := parseParams(r)
if params["consistent"] == "true" {
ctx = metadata.AppendToOutgoingContext(ctx, HeaderConsistencyMode, "consistent")
}

rsp, err := h.client.List(ctx, &pbresource.ListRequest{
Type: h.reg.Type,
Tenancy: tenancyInfo,
NamePrefix: params["namePrefix"],
})
if err != nil {
handleResponseError(err, w, h.logger)
return
}

output := make([]json.RawMessage, len(rsp.Resources))
for idx, res := range rsp.Resources {
b, err := jsonMarshal(res)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
h.logger.Error("Failed to unmarshal GRPC resource response", "error", err)
return
}
output[idx] = b
}

b, err := json.MarshalIndent(struct {
Resources []json.RawMessage `json:"resources"`
}{output}, "", " ")
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
h.logger.Error("Failed to correcty format the list response", "error", err)
return
}
w.Write(b)
}
109 changes: 109 additions & 0 deletions internal/resource/http/http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (

const testACLTokenArtistReadPolicy = "00000000-0000-0000-0000-000000000001"
const testACLTokenArtistWritePolicy = "00000000-0000-0000-0000-000000000002"
const testACLTokenArtistListPolicy = "00000000-0000-0000-0000-000000000003"
const fakeToken = "fake-token"

func parseToken(req *http.Request, token *string) {
Expand Down Expand Up @@ -300,6 +301,16 @@ func createResource(t *testing.T, artistHandler http.Handler) map[string]any {
return result
}

func deleteResource(t *testing.T, artistHandler http.Handler) {
rsp := httptest.NewRecorder()
req := httptest.NewRequest("DELETE", "/demo/v2/artist/keith-urban?partition=default&peer_name=local&namespace=default", strings.NewReader(""))

req.Header.Add("x-consul-token", testACLTokenArtistWritePolicy)

artistHandler.ServeHTTP(rsp, req)
require.Equal(t, http.StatusNoContent, rsp.Result().StatusCode)
}

func TestResourceReadHandler(t *testing.T) {
aclResolver := &resourceSvc.MockACLResolver{}
aclResolver.On("ResolveTokenAndDefaultMeta", testACLTokenArtistReadPolicy, mock.Anything, mock.Anything).
Expand Down Expand Up @@ -415,6 +426,7 @@ func TestResourceDeleteHandler(t *testing.T) {
req := httptest.NewRequest("DELETE", "/demo/v2/artist/keith-urban?partition=default&peer_name=local&namespace=default&version=1", strings.NewReader(""))

req.Header.Add("x-consul-token", testACLTokenArtistWritePolicy)
req.Header.Add("x-consul-token", testACLTokenArtistListPolicy)

handler.ServeHTTP(rsp, req)

Expand All @@ -430,3 +442,100 @@ func TestResourceDeleteHandler(t *testing.T) {
require.ErrorContains(t, err, "resource not found")
})
}

func TestResourceListHandler(t *testing.T) {
aclResolver := &resourceSvc.MockACLResolver{}
aclResolver.On("ResolveTokenAndDefaultMeta", testACLTokenArtistListPolicy, mock.Anything, mock.Anything).
Return(svctest.AuthorizerFrom(t, demo.ArtistV2ListPolicy), nil)
aclResolver.On("ResolveTokenAndDefaultMeta", testACLTokenArtistWritePolicy, mock.Anything, mock.Anything).
Return(svctest.AuthorizerFrom(t, demo.ArtistV2WritePolicy), nil)

client := svctest.RunResourceServiceWithACL(t, aclResolver, demo.RegisterTypes)

r := resource.NewRegistry()
demo.RegisterTypes(r)

handler := NewHandler(client, r, parseToken, hclog.NewNullLogger())

t.Run("should return MethodNotAllowed", func(t *testing.T) {
rsp := httptest.NewRecorder()
req := httptest.NewRequest("PUT", "/demo/v2/artist?partition=default&peer_name=local&namespace=default", strings.NewReader(""))

req.Header.Add("x-consul-token", testACLTokenArtistListPolicy)

handler.ServeHTTP(rsp, req)

require.Equal(t, http.StatusMethodNotAllowed, rsp.Result().StatusCode)
})

t.Run("should be blocked if the token is not authorized", func(t *testing.T) {
rsp := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/demo/v2/artist?partition=default&peer_name=local&namespace=default", strings.NewReader(""))

req.Header.Add("x-consul-token", testACLTokenArtistWritePolicy)

handler.ServeHTTP(rsp, req)

require.Equal(t, http.StatusForbidden, rsp.Result().StatusCode)
})

t.Run("should return list of resources", func(t *testing.T) {
resource := createResource(t, handler)

rsp := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/demo/v2/artist?partition=default&peer_name=local&namespace=default", strings.NewReader(""))

req.Header.Add("x-consul-token", testACLTokenArtistListPolicy)

handler.ServeHTTP(rsp, req)

require.Equal(t, http.StatusOK, rsp.Result().StatusCode)

var result map[string]any
require.NoError(t, json.NewDecoder(rsp.Body).Decode(&result))

resources, _ := result["resources"].([]any)
require.Len(t, resources, 1)

require.Equal(t, resource, resources[0])

// clean up
deleteResource(t, handler)
})

t.Run("should return empty list when no resources are found", func(t *testing.T) {
rsp := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/demo/v2/artist?partition=default&peer_name=local&namespace=default", strings.NewReader(""))

req.Header.Add("x-consul-token", testACLTokenArtistListPolicy)

handler.ServeHTTP(rsp, req)

require.Equal(t, http.StatusOK, rsp.Result().StatusCode)

var result map[string]any
require.NoError(t, json.NewDecoder(rsp.Body).Decode(&result))

resources, _ := result["resources"].([]any)
require.Len(t, resources, 0)
})

t.Run("should return empty list when name prefix matches don't match", func(t *testing.T) {
_ = createResource(t, handler)

rsp := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/demo/v2/artist?partition=default&peer_name=local&namespace=default&name_prefix=noname", strings.NewReader(""))

req.Header.Add("x-consul-token", testACLTokenArtistListPolicy)

handler.ServeHTTP(rsp, req)

require.Equal(t, http.StatusOK, rsp.Result().StatusCode)

var result map[string]any
require.NoError(t, json.NewDecoder(rsp.Body).Decode(&result))

resources, _ := result["resources"].([]any)
require.Len(t, resources, 0)
})
}

0 comments on commit 9ddc720

Please sign in to comment.