Skip to content

Commit

Permalink
Implement GET /v3/deployments
Browse files Browse the repository at this point in the history
Supported query parameters:
* `app_guids`
* `status_values`
* `order_by`

issue #3459

Co-authored-by: Georgi Sabev <[email protected]>
  • Loading branch information
danail-branekov and georgethebeatle committed Sep 13, 2024
1 parent 22e6d4c commit c3157df
Show file tree
Hide file tree
Showing 18 changed files with 807 additions and 24 deletions.
19 changes: 19 additions & 0 deletions api/handlers/deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const (
type CFDeploymentRepository interface {
GetDeployment(context.Context, authorization.Info, string) (repositories.DeploymentRecord, error)
CreateDeployment(context.Context, authorization.Info, repositories.CreateDeploymentMessage) (repositories.DeploymentRecord, error)
ListDeployments(context.Context, authorization.Info, repositories.ListDeploymentsMessage) ([]repositories.DeploymentRecord, error)
}

//counterfeiter:generate -o fake -fake-name RunnerInfoRepository . RunnerInfoRepository
Expand Down Expand Up @@ -106,6 +107,23 @@ func (h *Deployment) get(r *http.Request) (*routing.Response, error) {
return routing.NewResponse(http.StatusOK).WithBody(presenter.ForDeployment(deployment, h.serverURL)), nil
}

func (h *Deployment) list(r *http.Request) (*routing.Response, error) {
authInfo, _ := authorization.InfoFromContext(r.Context())
logger := logr.FromContextOrDiscard(r.Context()).WithName("handlers.deployment.list")

payload := new(payloads.DeploymentList)
if err := h.requestValidator.DecodeAndValidateURLValues(r, payload); err != nil {
return nil, apierrors.LogAndReturn(logger, err, "Unable to decode request query parameters")
}

deployments, err := h.deploymentRepo.ListDeployments(r.Context(), authInfo, payload.ToMessage())
if err != nil {
return nil, apierrors.LogAndReturn(logger, err, "Failed to fetch deployments from Kubernetes")
}

return routing.NewResponse(http.StatusOK).WithBody(presenter.ForList(presenter.ForDeployment, deployments, h.serverURL, *r.URL)), nil
}

func (h *Deployment) UnauthenticatedRoutes() []routing.Route {
return nil
}
Expand All @@ -114,5 +132,6 @@ func (h *Deployment) AuthenticatedRoutes() []routing.Route {
return []routing.Route{
{Method: "GET", Pattern: DeploymentPath, Handler: h.get},
{Method: "POST", Pattern: DeploymentsPath, Handler: h.create},
{Method: "GET", Pattern: DeploymentsPath, Handler: h.list},
}
}
47 changes: 47 additions & 0 deletions api/handlers/deployment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,4 +213,51 @@ var _ = Describe("Deployment", func() {
})
})
})

Describe("GET /v3/deployments", func() {
var deploymentRecord repositories.DeploymentRecord

BeforeEach(func() {
deploymentRecord = repositories.DeploymentRecord{
GUID: "deployment-guid",
}
deploymentsRepo.ListDeploymentsReturns([]repositories.DeploymentRecord{deploymentRecord}, nil)

payload := &payloads.DeploymentList{AppGUIDs: "bob,alice"}
requestValidator.DecodeAndValidateURLValuesStub = decodeAndValidateURLValuesStub(payload)

var err error
req, err = http.NewRequestWithContext(ctx, "GET", "/v3/deployments", nil)
Expect(err).NotTo(HaveOccurred())
})

It("returns the list of deployments", func() {
Expect(requestValidator.DecodeAndValidateURLValuesCallCount()).To(Equal(1))
actualReq, _ := requestValidator.DecodeAndValidateURLValuesArgsForCall(0)
Expect(actualReq.URL).To(Equal(req.URL))

Expect(deploymentsRepo.ListDeploymentsCallCount()).To(Equal(1))
_, _, listMessage := deploymentsRepo.ListDeploymentsArgsForCall(0)
Expect(listMessage).To(Equal(repositories.ListDeploymentsMessage{
AppGUIDs: []string{"bob", "alice"},
}))

Expect(rr).To(HaveHTTPStatus(http.StatusOK))
Expect(rr).To(HaveHTTPHeaderWithValue("Content-Type", "application/json"))
Expect(rr).To(HaveHTTPBody(SatisfyAll(
MatchJSONPath("$.resources", HaveLen(1)),
MatchJSONPath("$.resources[0].guid", "deployment-guid"),
)))
})

When("there is an error listing deployments", func() {
BeforeEach(func() {
deploymentsRepo.ListDeploymentsReturns(nil, errors.New("unexpected error!"))
})

It("returns an error", func() {
expectUnknownError()
})
})
})
})
83 changes: 83 additions & 0 deletions api/handlers/fake/cfdeployment_repository.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ func main() {
deploymentRepo := repositories.NewDeploymentRepo(
userClientFactory,
namespaceRetriever,
nsPermissions,
repositories.NewDeploymentSorter(),
)
buildRepo := repositories.NewBuildRepo(
namespaceRetriever,
Expand Down
72 changes: 67 additions & 5 deletions api/payloads/deployment.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
package payloads

import (
"fmt"
"net/url"
"regexp"
"slices"

"code.cloudfoundry.org/korifi/api/payloads/parse"
"code.cloudfoundry.org/korifi/api/payloads/validation"
"code.cloudfoundry.org/korifi/api/repositories"
"github.com/jellydator/validation"
"github.com/BooleanCat/go-functional/v2/it"
jellidation "github.com/jellydator/validation"
)

type DropletGUID struct {
Expand All @@ -15,8 +23,8 @@ type DeploymentCreate struct {
}

func (c DeploymentCreate) Validate() error {
return validation.ValidateStruct(&c,
validation.Field(&c.Relationships, validation.NotNil))
return jellidation.ValidateStruct(&c,
jellidation.Field(&c.Relationships, jellidation.NotNil))
}

func (c *DeploymentCreate) ToMessage() repositories.CreateDeploymentMessage {
Expand All @@ -31,6 +39,60 @@ type DeploymentRelationships struct {
}

func (r DeploymentRelationships) Validate() error {
return validation.ValidateStruct(&r,
validation.Field(&r.App, validation.NotNil))
return jellidation.ValidateStruct(&r,
jellidation.Field(&r.App, jellidation.NotNil))
}

type DeploymentList struct {
AppGUIDs string `json:"app_guids"`
OrderBy string `json:"order_by"`
StatusValues string `json:"status_values"`
}

func (d *DeploymentList) SupportedKeys() []string {
return []string{"app_guids", "status_values", "order_by"}
}

func (d *DeploymentList) IgnoredKeys() []*regexp.Regexp {
return []*regexp.Regexp{
regexp.MustCompile("page"),
regexp.MustCompile("per_page"),
}
}

func (d *DeploymentList) DecodeFromURLValues(values url.Values) error {
d.AppGUIDs = values.Get("app_guids")
d.OrderBy = values.Get("order_by")
d.StatusValues = values.Get("status_values")

return nil
}

func (d DeploymentList) Validate() error {
return jellidation.ValidateStruct(&d,
jellidation.Field(&d.OrderBy, validation.OneOfOrderBy("created_at", "updated_at")),
jellidation.Field(&d.StatusValues, jellidation.By(func(value any) error {
statusValues, ok := value.(string)
if !ok {
return fmt.Errorf("%T is not supported, string is expected", value)
}

return jellidation.Each(validation.OneOf(
"ACTIVE",
"FINALIZED",
)).Validate(parse.ArrayParam(statusValues))
})),
)
}

func (d *DeploymentList) ToMessage() repositories.ListDeploymentsMessage {
statusValues := slices.Collect(it.Map(slices.Values(parse.ArrayParam(d.StatusValues)), func(v string) repositories.DeploymentStatusValue {
return repositories.DeploymentStatusValue(v)
}))

return repositories.ListDeploymentsMessage{
AppGUIDs: parse.ArrayParam(d.AppGUIDs),
StatusValues: statusValues,
OrderBy: d.OrderBy,
}
}
48 changes: 48 additions & 0 deletions api/payloads/deployment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,51 @@ var _ = Describe("DeploymentCreate", func() {
})
})
})

var _ = Describe("DeploymentList", func() {
Describe("Validation", func() {
DescribeTable("valid query",
func(query string, expectedDeploymentList payloads.DeploymentList) {
actualDeploymentList, decodeErr := decodeQuery[payloads.DeploymentList](query)

Expect(decodeErr).NotTo(HaveOccurred())
Expect(*actualDeploymentList).To(Equal(expectedDeploymentList))
},

Entry("app_guids", "app_guids=app_guid", payloads.DeploymentList{AppGUIDs: "app_guid"}),
Entry("status_values ACTIVE", "status_values=ACTIVE", payloads.DeploymentList{StatusValues: "ACTIVE"}),
Entry("status_values FINALIZED", "status_values=FINALIZED", payloads.DeploymentList{StatusValues: "FINALIZED"}),
Entry("order_by created_at", "order_by=created_at", payloads.DeploymentList{OrderBy: "created_at"}),
Entry("order_by -created_at", "order_by=-created_at", payloads.DeploymentList{OrderBy: "-created_at"}),
Entry("order_by updated_at", "order_by=updated_at", payloads.DeploymentList{OrderBy: "updated_at"}),
Entry("order_by -updated_at", "order_by=-updated_at", payloads.DeploymentList{OrderBy: "-updated_at"}),
)

DescribeTable("invalid query",
func(query string, expectedErrMsg string) {
_, decodeErr := decodeQuery[payloads.DeploymentList](query)
Expect(decodeErr).To(MatchError(ContainSubstring(expectedErrMsg)))
},
Entry("invalid order_by", "order_by=foo", "value must be one of"),
Entry("invalid status_values", "status_values=foo", "value must be one of"),
)
})

Describe("ToMessage", func() {
It("translates to repository message", func() {
deploymentList := payloads.DeploymentList{
AppGUIDs: "app-guid1,app-guid2",
StatusValues: "ACTIVE,FINALIZED",
OrderBy: "created_at",
}
Expect(deploymentList.ToMessage()).To(Equal(repositories.ListDeploymentsMessage{
AppGUIDs: []string{"app-guid1", "app-guid2"},
StatusValues: []repositories.DeploymentStatusValue{
repositories.DeploymentStatusValueActive,
repositories.DeploymentStatusValueFinalized,
},
OrderBy: "created_at",
}))
})
})
})
8 changes: 1 addition & 7 deletions api/payloads/package.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,14 +117,8 @@ func (p *PackageList) DecodeFromURLValues(values url.Values) error {
}

func (p PackageList) Validate() error {
validOrderBys := []string{"created_at", "updated_at"}
var allowed []any
for _, a := range validOrderBys {
allowed = append(allowed, a, "-"+a)
}

return jellidation.ValidateStruct(&p,
jellidation.Field(&p.OrderBy, validation.OneOf(allowed...)),
jellidation.Field(&p.OrderBy, validation.OneOfOrderBy("created_at", "updated_at")),
)
}

Expand Down
4 changes: 4 additions & 0 deletions api/presenter/deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ type DeploymentResponse struct {
Droplet DropletGUID `json:"droplet"`
Relationships map[string]model.ToOneRelationship `json:"relationships"`
Links DeploymentLinks `json:"links"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}

type DeploymentLinks struct {
Expand All @@ -43,6 +45,8 @@ func ForDeployment(responseDeployment repositories.DeploymentRecord, baseURL url
Guid: responseDeployment.DropletGUID,
},
Relationships: ForRelationships(responseDeployment.Relationships()),
CreatedAt: formatTimestamp(&responseDeployment.CreatedAt),
UpdatedAt: formatTimestamp(responseDeployment.UpdatedAt),
Links: DeploymentLinks{
Self: Link{
HRef: buildURL(baseURL).appendPath(deploymentsBase, responseDeployment.GUID).build(),
Expand Down
Loading

0 comments on commit c3157df

Please sign in to comment.