Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/csm-portal/backend/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ func main() {
mux.HandleFunc("POST /products/search", productHandler.SearchProducts)
mux.HandleFunc("POST /products/{id}/versions/search", productHandler.SearchProductVersions)
mux.HandleFunc("POST /projects/{id}/deployments/search", deploymentHandler.SearchDeployments)
mux.HandleFunc("POST /deployments/{id}/products/search", deploymentHandler.SearchDeployedProducts)

addr := envOrDefault("PORT", ":8080")
slog.Info("starting csm-portal backend", "addr", addr)
Expand Down
6 changes: 6 additions & 0 deletions apps/csm-portal/backend/internal/entity/entity.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,9 @@ func (c *Client) SearchProductVersions(ctx context.Context, productID string, bo
func (c *Client) SearchDeployments(ctx context.Context, body []byte) ([]byte, error) {
return c.do(ctx, http.MethodPost, "/deployments/search", body)
}

// SearchDeployedProducts calls POST /deployed-products/search on the entity service.
// Response is returned as raw JSON; field filtering to the portal shape is deferred.
func (c *Client) SearchDeployedProducts(ctx context.Context, body []byte) ([]byte, error) {
return c.do(ctx, http.MethodPost, "/deployed-products/search", body)
}
65 changes: 65 additions & 0 deletions apps/csm-portal/backend/internal/handler/deployments.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,27 @@ import (
"github.com/wso2-open-operations/cs-tools/apps/csm-portal/backend/internal/middleware"
)

// injectDeploymentID merges a deployment ID into a JSON request body as deploymentIds: [id].
func injectDeploymentID(body []byte, deploymentID string) ([]byte, error) {
var m map[string]json.RawMessage
if err := json.Unmarshal(body, &m); err != nil {
return nil, err
}
if m == nil {
m = make(map[string]json.RawMessage)
}
ids, err := json.Marshal([]string{deploymentID})
if err != nil {
return nil, err
}
m["deploymentIds"] = ids
return json.Marshal(m)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// entityDeploymentClient abstracts the entity service deployment operations used by DeploymentHandler.
type entityDeploymentClient interface {
SearchDeployments(ctx context.Context, body []byte) ([]byte, error)
SearchDeployedProducts(ctx context.Context, body []byte) ([]byte, error)
}

// DeploymentHandler handles HTTP requests for deployment operations, delegating to the
Expand Down Expand Up @@ -86,3 +104,50 @@ func (h *DeploymentHandler) SearchDeployments(w http.ResponseWriter, r *http.Req
// TODO: Unmarshal result and filter to only the fields required by the frontend.
writeJSON(w, http.StatusOK, result)
}

// SearchDeployedProducts handles POST /deployments/{id}/products/search.
func (h *DeploymentHandler) SearchDeployedProducts(w http.ResponseWriter, r *http.Request) {
user := middleware.UserInfoFromContext(r.Context())
if user == nil {
writeError(w, http.StatusUnauthorized, ErrMsgUnauthorized)
return
}

deploymentID := r.PathValue("id")
if deploymentID == "" {
writeError(w, http.StatusBadRequest, ErrMsgBadRequest)
return
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodyBytes)
body, err := io.ReadAll(r.Body)
if err != nil {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
writeError(w, http.StatusRequestEntityTooLarge, ErrMsgTooLarge)
return
}
writeError(w, http.StatusBadRequest, errMsgReadBody)
return
}

if !json.Valid(body) {
writeError(w, http.StatusBadRequest, ErrMsgBadRequest)
return
}

entityBody, err := injectDeploymentID(body, deploymentID)
if err != nil {
writeError(w, http.StatusBadRequest, ErrMsgBadRequest)
return
}

result, err := h.entity.SearchDeployedProducts(r.Context(), entityBody)
if err != nil {
slog.ErrorContext(r.Context(), "entity SearchDeployedProducts failed", "userID", user.UserID, "deploymentID", deploymentID, "err", err)
mapUpstreamError(w, err, "Failed to search deployed products.")
return
}

writeJSON(w, http.StatusOK, result)
}
110 changes: 110 additions & 0 deletions apps/csm-portal/backend/internal/handler/deployments_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package handler

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
Expand Down Expand Up @@ -115,3 +116,112 @@ func TestSearchDeployments(t *testing.T) {
}
})
}

func TestSearchDeployedProducts(t *testing.T) {
t.Run("requires authenticated user", func(t *testing.T) {
h := NewDeploymentHandler(&mockEntityDeploymentClient{})
r := httptest.NewRequest(http.MethodPost, "/deployments/dep-1/products/search", strings.NewReader(`{}`))
r.SetPathValue("id", "dep-1")
w := httptest.NewRecorder()
h.SearchDeployedProducts(w, r)
assertStatus(t, w, http.StatusUnauthorized)
assertErrorMessage(t, w, ErrMsgUnauthorized)
assertContentType(t, w, "application/json")
})

t.Run("rejects body exceeding 1 MiB", func(t *testing.T) {
h := NewDeploymentHandler(&mockEntityDeploymentClient{})
r := withUser(httptest.NewRequest(http.MethodPost, "/deployments/dep-1/products/search", strings.NewReader(strings.Repeat("x", maxRequestBodyBytes+1))))
r.SetPathValue("id", "dep-1")
w := httptest.NewRecorder()
h.SearchDeployedProducts(w, r)
assertStatus(t, w, http.StatusRequestEntityTooLarge)
assertErrorMessage(t, w, ErrMsgTooLarge)
assertContentType(t, w, "application/json")
})

t.Run("rejects invalid JSON body", func(t *testing.T) {
h := NewDeploymentHandler(&mockEntityDeploymentClient{})
r := withUser(httptest.NewRequest(http.MethodPost, "/deployments/dep-1/products/search", strings.NewReader(`not-json`)))
r.SetPathValue("id", "dep-1")
w := httptest.NewRecorder()
h.SearchDeployedProducts(w, r)
assertStatus(t, w, http.StatusBadRequest)
assertErrorMessage(t, w, ErrMsgBadRequest)
assertContentType(t, w, "application/json")
})

t.Run("handles JSON null body as empty object without panic", func(t *testing.T) {
h := NewDeploymentHandler(&mockEntityDeploymentClient{})
r := withUser(httptest.NewRequest(http.MethodPost, "/deployments/dep-1/products/search", strings.NewReader(`null`)))
r.SetPathValue("id", "dep-1")
w := httptest.NewRecorder()
h.SearchDeployedProducts(w, r)
assertStatus(t, w, http.StatusOK)
assertContentType(t, w, "application/json")
})

t.Run("rejects missing deployment id", func(t *testing.T) {
h := NewDeploymentHandler(&mockEntityDeploymentClient{})
r := withUser(httptest.NewRequest(http.MethodPost, "/deployments//products/search", strings.NewReader(`{}`)))
w := httptest.NewRecorder()
h.SearchDeployedProducts(w, r)
assertStatus(t, w, http.StatusBadRequest)
assertErrorMessage(t, w, ErrMsgBadRequest)
assertContentType(t, w, "application/json")
})

t.Run("injects deploymentId and returns 200 with response", func(t *testing.T) {
const deploymentID = "dep-1"
var capturedBody []byte
client := &mockEntityDeploymentClient{
searchDeployedProductsFn: func(_ context.Context, body []byte) ([]byte, error) {
capturedBody = body
return []byte(`{"deployedProducts":[{"id":"dp-1","deploymentId":"dep-1"}],"total":1}`), nil
},
}
h := NewDeploymentHandler(client)
r := withUser(httptest.NewRequest(http.MethodPost, "/deployments/dep-1/products/search", strings.NewReader(`{"pagination":{"limit":10,"offset":0}}`)))
r.SetPathValue("id", deploymentID)
w := httptest.NewRecorder()
h.SearchDeployedProducts(w, r)

assertStatus(t, w, http.StatusOK)
assertContentType(t, w, "application/json")

var sent map[string]json.RawMessage
if err := json.Unmarshal(capturedBody, &sent); err != nil {
t.Fatalf("upstream received invalid JSON: %v", err)
}
var ids []string
if err := json.Unmarshal(sent["deploymentIds"], &ids); err != nil || len(ids) != 1 || ids[0] != deploymentID {
t.Errorf("upstream deploymentIds = %v, want [%q]", ids, deploymentID)
}

resp := decodeJSON[map[string]any](t, w)
if resp["total"] != float64(1) {
t.Errorf("total = %v, want 1", resp["total"])
}
})

t.Run("upstream errors are mapped correctly", func(t *testing.T) {
for _, tc := range upstreamErrors("Failed to search deployed products.") {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
client := &mockEntityDeploymentClient{
searchDeployedProductsFn: func(_ context.Context, _ []byte) ([]byte, error) {
return nil, tc.err
},
}
h := NewDeploymentHandler(client)
r := withUser(httptest.NewRequest(http.MethodPost, "/deployments/dep-1/products/search", strings.NewReader(`{}`)))
r.SetPathValue("id", "dep-1")
w := httptest.NewRecorder()
h.SearchDeployedProducts(w, r)
assertStatus(t, w, tc.wantCode)
assertErrorMessage(t, w, tc.wantMsg)
assertContentType(t, w, "application/json")
})
}
})
}
10 changes: 9 additions & 1 deletion apps/csm-portal/backend/internal/handler/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,8 @@ func (m *mockEntityProductClient) SearchProductVersions(ctx context.Context, pro
// ----- mock entity deployment client -----

type mockEntityDeploymentClient struct {
searchDeploymentsFn func(ctx context.Context, body []byte) ([]byte, error)
searchDeploymentsFn func(ctx context.Context, body []byte) ([]byte, error)
searchDeployedProductsFn func(ctx context.Context, body []byte) ([]byte, error)
}

func (m *mockEntityDeploymentClient) SearchDeployments(ctx context.Context, body []byte) ([]byte, error) {
Expand All @@ -225,3 +226,10 @@ func (m *mockEntityDeploymentClient) SearchDeployments(ctx context.Context, body
}
return []byte(`{}`), nil
}

func (m *mockEntityDeploymentClient) SearchDeployedProducts(ctx context.Context, body []byte) ([]byte, error) {
if m.searchDeployedProductsFn != nil {
return m.searchDeployedProductsFn(ctx, body)
}
return []byte(`{}`), nil
}
92 changes: 92 additions & 0 deletions apps/csm-portal/backend/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,52 @@ paths:
schema:
$ref: '#/components/schemas/ErrorPayload'

/deployments/{id}/products/search:
post:
summary: Search deployed products for a specific deployment.
operationId: postDeploymentProductsSearch
parameters:
- name: id
in: path
description: ID of the deployment
required: true
schema:
type: string
requestBody:
description: Deployed product search payload
content:
application/json:
schema:
$ref: '#/components/schemas/DeployedProductSearchPayload'
required: true
responses:
"200":
description: Ok
content:
application/json:
schema:
$ref: '#/components/schemas/DeployedProductSearchResponse'
"400":
description: BadRequest
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorPayload'
"401":
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorPayload'
"413":
description: RequestEntityTooLarge
"500":
description: InternalServerError
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorPayload'

/products/search:
post:
summary: Search products.
Expand Down Expand Up @@ -955,6 +1001,52 @@ components:
type: string
format: date-time

DeployedProductSearchPayload:
type: object
properties:
pagination:
allOf:
- $ref: '#/components/schemas/Pagination'
- properties:
limit:
default: 20
maximum: 100

DeployedProductSearchResponse:
type: object
properties:
deployedProducts:
type: array
items:
$ref: '#/components/schemas/DeployedProduct'
total:
type: integer
description: Total number of matching deployed products
limit:
type: integer
offset:
type: integer
hasMore:
type: boolean

DeployedProduct:
type: object
properties:
id:
type: string
deploymentId:
type: string
productId:
type: string
productVersionId:
type: string
createdAt:
type: string
format: date-time
updatedAt:
type: string
format: date-time

Pagination:
type: object
properties:
Expand Down