Skip to content

Commit a96c0e6

Browse files
committed
feat(httpserver): Add update subscription endpoint
This changes adds a new HTTP endpoint to update existing subscriptions. It allows clients to modify the triggers and frequency of their subscriptions. The implementation includes validation of input data and appropriate error handling.
1 parent d58c196 commit a96c0e6

4 files changed

Lines changed: 511 additions & 1 deletion

File tree

backend/pkg/httpserver/server_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1245,6 +1245,16 @@ func (m *mockServerInterface) CreateSubscription(ctx context.Context,
12451245
panic("unimplemented")
12461246
}
12471247

1248+
// UpdateSubscription implements backend.StrictServerInterface.
1249+
// nolint: ireturn // WONTFIX - generated method signature
1250+
func (m *mockServerInterface) UpdateSubscription(ctx context.Context,
1251+
_ backend.UpdateSubscriptionRequestObject) (
1252+
backend.UpdateSubscriptionResponseObject, error) {
1253+
assertUserInCtx(ctx, m.t, m.expectedUserInCtx)
1254+
m.callCount++
1255+
panic("unimplemented")
1256+
}
1257+
12481258
func (m *mockServerInterface) assertCallCount(expectedCallCount int) {
12491259
if m.callCount != expectedCallCount {
12501260
m.t.Errorf("expected mock server to be used %d times. only used %d times", expectedCallCount, m.callCount)
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package httpserver
16+
17+
import (
18+
"context"
19+
"errors"
20+
"fmt"
21+
"log/slog"
22+
"net/http"
23+
"slices"
24+
25+
"github.com/GoogleChrome/webstatus.dev/lib/backendtypes"
26+
"github.com/GoogleChrome/webstatus.dev/lib/gen/openapi/backend"
27+
)
28+
29+
var (
30+
errSubscriptionUpdateMaskRequired = errors.New("update_mask is required")
31+
errSubscriptionInvalidUpdateMask = fmt.Errorf("update_mask must be one of the following: %s",
32+
getAllSubscriptionUpdateMasksToStringSlice())
33+
)
34+
35+
func getAllSubscriptionUpdateMasksSet() map[backend.UpdateSubscriptionRequestUpdateMask]any {
36+
return map[backend.UpdateSubscriptionRequestUpdateMask]any{
37+
backend.UpdateSubscriptionRequestMaskTriggers: nil,
38+
backend.UpdateSubscriptionRequestMaskFrequency: nil,
39+
}
40+
}
41+
42+
func getAllSubscriptionUpdateMasksToStringSlice() []string {
43+
allUpdateMasks := getAllSubscriptionUpdateMasksSet()
44+
allUpdateMasksSlice := make([]string, 0, len(allUpdateMasks))
45+
for updateMask := range allUpdateMasks {
46+
allUpdateMasksSlice = append(allUpdateMasksSlice, string(updateMask))
47+
}
48+
slices.Sort(allUpdateMasksSlice)
49+
50+
return allUpdateMasksSlice
51+
}
52+
53+
func validateSubscriptionUpdateMask(updateMask *[]backend.UpdateSubscriptionRequestUpdateMask,
54+
required bool, fieldErrors *fieldValidationErrors) {
55+
if updateMask == nil || len(*updateMask) == 0 {
56+
if required {
57+
fieldErrors.addFieldError("update_mask", errSubscriptionUpdateMaskRequired)
58+
}
59+
60+
return
61+
}
62+
63+
set := getAllSubscriptionUpdateMasksSet()
64+
for _, updateMask := range *updateMask {
65+
if _, ok := set[updateMask]; !ok {
66+
fieldErrors.addFieldError("update_mask", errSubscriptionInvalidUpdateMask)
67+
}
68+
}
69+
}
70+
71+
func validateSubscriptionUpdate(input *backend.UpdateSubscriptionRequest) *fieldValidationErrors {
72+
fieldErrors := &fieldValidationErrors{fieldErrorMap: nil}
73+
74+
validateSubscriptionUpdateMask(&input.UpdateMask, true, fieldErrors)
75+
76+
isTriggerRequired := slices.Contains(input.UpdateMask, backend.UpdateSubscriptionRequestMaskTriggers)
77+
validateSubscriptionTrigger(input.Triggers, isTriggerRequired, fieldErrors)
78+
79+
isFrequencyRequired := slices.Contains(input.UpdateMask, backend.UpdateSubscriptionRequestMaskFrequency)
80+
validateSubscriptionFrequency(input.Frequency, isFrequencyRequired, fieldErrors)
81+
82+
if fieldErrors.hasErrors() {
83+
return fieldErrors
84+
}
85+
86+
return nil
87+
}
88+
89+
// nolint:ireturn, revive // Expected ireturn for openapi generation.
90+
func (s *Server) UpdateSubscription(
91+
ctx context.Context,
92+
request backend.UpdateSubscriptionRequestObject,
93+
) (backend.UpdateSubscriptionResponseObject, error) {
94+
userCheck := CheckAuthenticatedUser[backend.UpdateSubscriptionResponseObject](ctx, "UpdateSubscription",
95+
func(code int, message string) backend.UpdateSubscriptionResponseObject {
96+
return backend.UpdateSubscription500JSONResponse(backend.BasicErrorModel{Code: code, Message: message})
97+
})
98+
if userCheck.User == nil {
99+
return userCheck.Response, nil
100+
}
101+
102+
validationErr := validateSubscriptionUpdate(request.Body)
103+
if validationErr != nil {
104+
return backend.UpdateSubscription400JSONResponse{
105+
Code: http.StatusBadRequest,
106+
Message: "input validation errors",
107+
Errors: validationErr.fieldErrorMap,
108+
}, nil
109+
}
110+
111+
resp, err := s.wptMetricsStorer.UpdateSavedSearchSubscription(
112+
ctx, userCheck.User.ID, request.SubscriptionId, *request.Body)
113+
if err != nil {
114+
if errors.Is(err, backendtypes.ErrEntityDoesNotExist) {
115+
return backend.UpdateSubscription404JSONResponse(
116+
backend.BasicErrorModel{
117+
Code: http.StatusNotFound,
118+
Message: "subscription not found",
119+
},
120+
), nil
121+
} else if errors.Is(err, backendtypes.ErrUserNotAuthorizedForAction) {
122+
return backend.UpdateSubscription403JSONResponse(
123+
backend.BasicErrorModel{
124+
Code: http.StatusForbidden,
125+
Message: "user not authorized to update this subscription",
126+
},
127+
), nil
128+
}
129+
130+
slog.ErrorContext(ctx, "failed to update subscription", "error", err)
131+
132+
return backend.UpdateSubscription500JSONResponse{
133+
Code: http.StatusInternalServerError,
134+
Message: "could not update subscription",
135+
}, nil
136+
}
137+
138+
return backend.UpdateSubscription200JSONResponse(*resp), nil
139+
}

0 commit comments

Comments
 (0)