Skip to content

Commit d58c196

Browse files
authored
feat(httpserver): add create subscription endpoint (#2046)
This changes adds a new HTTP endpoint to create saved search subscriptions. It validates the incoming request, ensuring that the subscription triggers are valid, the frequency is supported, and that the saved search ID and channel ID are provided. The endpoint interacts with the backend server interface to create the subscription and returns the created subscription in the response.
1 parent 0904a62 commit d58c196

4 files changed

Lines changed: 528 additions & 2 deletions

File tree

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
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+
// The exhaustive linter is configured to check that this map is complete.
30+
func getAllSubscriptionTriggersSet() map[backend.SubscriptionTriggerWritable]any {
31+
return map[backend.SubscriptionTriggerWritable]any{
32+
backend.SubscriptionTriggerFeatureAnyBrowserImplementationComplete: nil,
33+
backend.SubscriptionTriggerFeatureBaselineLimitedToNewly: nil,
34+
backend.SubscriptionTriggerFeatureBaselineRegressionNewlyToLimited: nil,
35+
}
36+
}
37+
38+
func getAllSubscriptionTriggersToStringSlice() []string {
39+
allTriggers := getAllSubscriptionTriggersSet()
40+
allTriggersSlice := make([]string, 0, len(allTriggers))
41+
for trigger := range allTriggers {
42+
allTriggersSlice = append(allTriggersSlice, string(trigger))
43+
}
44+
slices.Sort(allTriggersSlice)
45+
46+
return allTriggersSlice
47+
}
48+
49+
var (
50+
errSubscriptionInvalidTrigger = fmt.Errorf("triggers must be one of the following: %s",
51+
getAllSubscriptionTriggersToStringSlice())
52+
errSubscriptionInvalidFrequency = fmt.Errorf("frequency must be one of the following: %s",
53+
getAllSubscriptionFrequenciesToStringSlice())
54+
errSubscriptionChannelIDRequired = errors.New("channel_id is required")
55+
errSubscriptionSavedSearchIDRequired = errors.New("saved_search_id is required")
56+
)
57+
58+
func validateSubscriptionTrigger(trigger *[]backend.SubscriptionTriggerWritable,
59+
required bool, fieldErrors *fieldValidationErrors) {
60+
if trigger == nil {
61+
if required {
62+
fieldErrors.addFieldError("triggers", errSubscriptionInvalidTrigger)
63+
}
64+
65+
return
66+
}
67+
68+
set := getAllSubscriptionTriggersSet()
69+
for _, trigger := range *trigger {
70+
if _, ok := set[trigger]; !ok {
71+
fieldErrors.addFieldError("triggers", errSubscriptionInvalidTrigger)
72+
}
73+
}
74+
}
75+
76+
// The exhaustive linter is configured to check that this map is complete.
77+
func getAllSubscriptionFrequenciesSet() map[backend.SubscriptionFrequency]any {
78+
return map[backend.SubscriptionFrequency]any{
79+
backend.SubscriptionFrequencyDaily: nil,
80+
}
81+
}
82+
83+
func getAllSubscriptionFrequenciesToStringSlice() []string {
84+
allFrequencies := getAllSubscriptionFrequenciesSet()
85+
allFrequenciesSlice := make([]string, 0, len(allFrequencies))
86+
for frequency := range allFrequencies {
87+
allFrequenciesSlice = append(allFrequenciesSlice, string(frequency))
88+
}
89+
slices.Sort(allFrequenciesSlice)
90+
91+
return allFrequenciesSlice
92+
}
93+
94+
func validateSubscriptionFrequency(frequency *backend.SubscriptionFrequency,
95+
required bool, fieldErrors *fieldValidationErrors) {
96+
if frequency == nil {
97+
if required {
98+
fieldErrors.addFieldError("frequency", errSubscriptionInvalidFrequency)
99+
}
100+
101+
return
102+
}
103+
104+
set := getAllSubscriptionFrequenciesSet()
105+
if _, ok := set[*frequency]; !ok {
106+
fieldErrors.addFieldError("frequency", errSubscriptionInvalidFrequency)
107+
}
108+
}
109+
110+
func validateSubscriptionChannelID(channelID string, fieldErrors *fieldValidationErrors) {
111+
if channelID == "" {
112+
fieldErrors.addFieldError("channel_id", errSubscriptionChannelIDRequired)
113+
114+
return
115+
}
116+
}
117+
118+
func validateSubscriptionSavedSearchID(savedSearchID string, fieldErrors *fieldValidationErrors) {
119+
if savedSearchID == "" {
120+
fieldErrors.addFieldError("saved_search_id", errSubscriptionSavedSearchIDRequired)
121+
122+
return
123+
}
124+
}
125+
126+
func validateSubscriptionCreation(input *backend.Subscription) *fieldValidationErrors {
127+
fieldErrors := &fieldValidationErrors{fieldErrorMap: nil}
128+
129+
validateSubscriptionTrigger(&input.Triggers, true, fieldErrors)
130+
131+
validateSubscriptionFrequency(&input.Frequency, true, fieldErrors)
132+
133+
validateSubscriptionChannelID(input.ChannelId, fieldErrors)
134+
135+
validateSubscriptionSavedSearchID(input.SavedSearchId, fieldErrors)
136+
137+
if fieldErrors.hasErrors() {
138+
return fieldErrors
139+
}
140+
141+
return nil
142+
}
143+
144+
// nolint:ireturn, revive // Expected ireturn for openapi generation.
145+
func (s *Server) CreateSubscription(
146+
ctx context.Context,
147+
request backend.CreateSubscriptionRequestObject,
148+
) (backend.CreateSubscriptionResponseObject, error) {
149+
userCheck := CheckAuthenticatedUser[backend.CreateSubscriptionResponseObject](ctx, "CreateSubscription",
150+
func(code int, message string) backend.CreateSubscriptionResponseObject {
151+
return backend.CreateSubscription500JSONResponse(backend.BasicErrorModel{Code: code, Message: message})
152+
})
153+
if userCheck.User == nil {
154+
return userCheck.Response, nil
155+
}
156+
validationErr := validateSubscriptionCreation(request.Body)
157+
if validationErr != nil {
158+
return backend.CreateSubscription400JSONResponse{
159+
Code: http.StatusBadRequest,
160+
Message: "input validation errors",
161+
Errors: validationErr.fieldErrorMap,
162+
}, nil
163+
}
164+
165+
resp, err := s.wptMetricsStorer.CreateSavedSearchSubscription(ctx, userCheck.User.ID, *request.Body)
166+
if err != nil {
167+
if errors.Is(err, backendtypes.ErrUserNotAuthorizedForAction) {
168+
return backend.CreateSubscription403JSONResponse(
169+
backend.BasicErrorModel{
170+
Code: http.StatusForbidden,
171+
Message: "user not authorized to create this subscription using the specified channel",
172+
},
173+
), nil
174+
}
175+
slog.ErrorContext(ctx, "failed to create subscription", "error", err)
176+
177+
return backend.CreateSubscription500JSONResponse{
178+
Code: http.StatusInternalServerError,
179+
Message: "could not create subscription",
180+
}, nil
181+
}
182+
183+
return backend.CreateSubscription201JSONResponse(*resp), nil
184+
}

0 commit comments

Comments
 (0)