Skip to content

Commit 143de3a

Browse files
authored
feat(httpserver): add get subscription endpoint (#2049)
This change introduces a new HTTP endpoint to retrieve individual subscription details by ID. It includes the necessary handler, routing, and tests to ensure proper functionality.
1 parent 56f5fc2 commit 143de3a

File tree

4 files changed

+273
-1
lines changed

4 files changed

+273
-1
lines changed
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
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+
"log/slog"
21+
"net/http"
22+
23+
"github.com/GoogleChrome/webstatus.dev/lib/backendtypes"
24+
"github.com/GoogleChrome/webstatus.dev/lib/gen/openapi/backend"
25+
)
26+
27+
// nolint:ireturn, revive // Expected ireturn for openapi generation.
28+
func (s *Server) GetSubscription(
29+
ctx context.Context,
30+
request backend.GetSubscriptionRequestObject,
31+
) (backend.GetSubscriptionResponseObject, error) {
32+
userCheck := CheckAuthenticatedUser[backend.GetSubscriptionResponseObject](ctx, "GetSubscription",
33+
func(code int, message string) backend.GetSubscriptionResponseObject {
34+
return backend.GetSubscription500JSONResponse(backend.BasicErrorModel{Code: code, Message: message})
35+
})
36+
if userCheck.User == nil {
37+
return userCheck.Response, nil
38+
}
39+
40+
resp, err := s.wptMetricsStorer.GetSavedSearchSubscription(ctx, userCheck.User.ID, request.SubscriptionId)
41+
if err != nil {
42+
if errors.Is(err, backendtypes.ErrEntityDoesNotExist) {
43+
return backend.GetSubscription404JSONResponse(
44+
backend.BasicErrorModel{
45+
Code: http.StatusNotFound,
46+
Message: "subscription not found",
47+
},
48+
), nil
49+
} else if errors.Is(err, backendtypes.ErrUserNotAuthorizedForAction) {
50+
return backend.GetSubscription403JSONResponse(
51+
backend.BasicErrorModel{
52+
Code: http.StatusForbidden,
53+
Message: "user not authorized to access this subscription",
54+
},
55+
), nil
56+
}
57+
58+
slog.ErrorContext(ctx, "failed to get subscription", "error", err)
59+
60+
return backend.GetSubscription500JSONResponse(
61+
backend.BasicErrorModel{
62+
Code: http.StatusInternalServerError,
63+
Message: "could not get subscription",
64+
},
65+
), nil
66+
}
67+
68+
return backend.GetSubscription200JSONResponse(*resp), nil
69+
}
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
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+
"net/http"
19+
"net/http/httptest"
20+
"testing"
21+
"time"
22+
23+
"github.com/GoogleChrome/webstatus.dev/lib/auth"
24+
"github.com/GoogleChrome/webstatus.dev/lib/backendtypes"
25+
"github.com/GoogleChrome/webstatus.dev/lib/gen/openapi/backend"
26+
)
27+
28+
func TestGetSubscription(t *testing.T) {
29+
now := time.Now()
30+
testUser := &auth.User{
31+
ID: "test-user",
32+
GitHubUserID: nil,
33+
}
34+
35+
testCases := []struct {
36+
name string
37+
cfg *MockGetSavedSearchSubscriptionConfig
38+
expectedCallCount int
39+
authMiddlewareOption testServerOption
40+
request *http.Request
41+
expectedResponse *http.Response
42+
}{
43+
{
44+
name: "success",
45+
cfg: &MockGetSavedSearchSubscriptionConfig{
46+
expectedUserID: "test-user",
47+
expectedSubscriptionID: "sub-id",
48+
output: &backend.SubscriptionResponse{
49+
Id: "sub-id",
50+
ChannelId: "channel-id",
51+
SavedSearchId: "search-id",
52+
Triggers: []backend.SubscriptionTriggerResponseItem{
53+
{
54+
Value: backendtypes.AttemptToStoreSubscriptionTrigger("trigger"),
55+
RawValue: nil,
56+
},
57+
},
58+
Frequency: "daily",
59+
CreatedAt: now,
60+
UpdatedAt: now,
61+
},
62+
err: nil,
63+
},
64+
expectedCallCount: 1,
65+
authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)),
66+
request: httptest.NewRequest(
67+
http.MethodGet,
68+
"/v1/users/me/subscriptions/sub-id",
69+
nil,
70+
),
71+
expectedResponse: testJSONResponse(http.StatusOK,
72+
`{
73+
"id":"sub-id","channel_id":"channel-id",
74+
"saved_search_id":"search-id",
75+
"triggers":[{"value":"trigger"}],
76+
"frequency":"daily",
77+
"created_at":"`+now.Format(time.RFC3339Nano)+`",
78+
"updated_at":"`+now.Format(time.RFC3339Nano)+`"}`),
79+
},
80+
{
81+
name: "not found",
82+
cfg: &MockGetSavedSearchSubscriptionConfig{
83+
expectedUserID: "test-user",
84+
expectedSubscriptionID: "sub-id",
85+
output: nil,
86+
err: backendtypes.ErrEntityDoesNotExist,
87+
},
88+
expectedCallCount: 1,
89+
authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)),
90+
request: httptest.NewRequest(
91+
http.MethodGet,
92+
"/v1/users/me/subscriptions/sub-id",
93+
nil,
94+
),
95+
expectedResponse: testJSONResponse(http.StatusNotFound, `{"code":404,"message":"subscription not found"}`),
96+
},
97+
{
98+
name: "forbidden - user cannot access subscription",
99+
cfg: &MockGetSavedSearchSubscriptionConfig{
100+
expectedUserID: "test-user",
101+
expectedSubscriptionID: "sub-id",
102+
output: nil,
103+
err: backendtypes.ErrUserNotAuthorizedForAction,
104+
},
105+
expectedCallCount: 1,
106+
authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)),
107+
request: httptest.NewRequest(
108+
http.MethodGet,
109+
"/v1/users/me/subscriptions/sub-id",
110+
nil,
111+
),
112+
expectedResponse: testJSONResponse(http.StatusForbidden,
113+
`{"code":403,"message":"user not authorized to access this subscription"}`),
114+
},
115+
{
116+
name: "internal server error",
117+
cfg: &MockGetSavedSearchSubscriptionConfig{
118+
expectedUserID: "test-user",
119+
expectedSubscriptionID: "sub-id",
120+
output: nil,
121+
err: errTest,
122+
},
123+
expectedCallCount: 1,
124+
authMiddlewareOption: withAuthMiddleware(mockAuthMiddleware(testUser)),
125+
request: httptest.NewRequest(
126+
http.MethodGet,
127+
"/v1/users/me/subscriptions/sub-id",
128+
nil,
129+
),
130+
expectedResponse: testJSONResponse(http.StatusInternalServerError,
131+
`{"code":500,"message":"could not get subscription"}`),
132+
},
133+
}
134+
135+
for _, tc := range testCases {
136+
t.Run(tc.name, func(t *testing.T) {
137+
//nolint:exhaustruct
138+
mockStorer := &MockWPTMetricsStorer{
139+
getSavedSearchSubscriptionCfg: tc.cfg,
140+
t: t,
141+
}
142+
myServer := Server{
143+
wptMetricsStorer: mockStorer,
144+
metadataStorer: nil,
145+
userGitHubClientFactory: nil,
146+
operationResponseCaches: nil,
147+
baseURL: getTestBaseURL(t),
148+
}
149+
assertTestServerRequest(t, &myServer, tc.request, tc.expectedResponse, tc.authMiddlewareOption)
150+
assertMocksExpectations(t,
151+
tc.expectedCallCount,
152+
mockStorer.callCountGetSavedSearchSubscription,
153+
"GetSavedSearchSubscription",
154+
nil)
155+
})
156+
}
157+
}

backend/pkg/httpserver/server_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1265,6 +1265,16 @@ func (m *mockServerInterface) ListSubscriptions(ctx context.Context,
12651265
panic("unimplemented")
12661266
}
12671267

1268+
// GetSubscription implements backend.StrictServerInterface.
1269+
// nolint: ireturn // WONTFIX - generated method signature
1270+
func (m *mockServerInterface) GetSubscription(ctx context.Context,
1271+
_ backend.GetSubscriptionRequestObject) (
1272+
backend.GetSubscriptionResponseObject, error) {
1273+
assertUserInCtx(ctx, m.t, m.expectedUserInCtx)
1274+
m.callCount++
1275+
panic("unimplemented")
1276+
}
1277+
12681278
func (m *mockServerInterface) assertCallCount(expectedCallCount int) {
12691279
if m.callCount != expectedCallCount {
12701280
m.t.Errorf("expected mock server to be used %d times. only used %d times", expectedCallCount, m.callCount)

openapi/backend/openapi.yaml

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1157,7 +1157,43 @@ paths:
11571157
required: true
11581158
schema:
11591159
type: string
1160-
# GET operation to retrieve a specific subscription (to be added in a future PR)
1160+
# GET operation to retrieve a specific subscription
1161+
get:
1162+
summary: Get a subscription for a saved search
1163+
operationId: getSubscription
1164+
security:
1165+
- bearerAuth: []
1166+
responses:
1167+
'200':
1168+
description: OK
1169+
content:
1170+
application/json:
1171+
schema:
1172+
$ref: '#/components/schemas/SubscriptionResponse'
1173+
'401':
1174+
description: Unauthorized
1175+
content:
1176+
application/json:
1177+
schema:
1178+
$ref: '#/components/schemas/BasicErrorModel'
1179+
'403':
1180+
description: Forbidden
1181+
content:
1182+
application/json:
1183+
schema:
1184+
$ref: '#/components/schemas/BasicErrorModel'
1185+
'404':
1186+
description: Not Found
1187+
content:
1188+
application/json:
1189+
schema:
1190+
$ref: '#/components/schemas/BasicErrorModel'
1191+
'500':
1192+
description: Internal Service Error
1193+
content:
1194+
application/json:
1195+
schema:
1196+
$ref: '#/components/schemas/BasicErrorModel'
11611197
# PATCH operation to update a specific subscription
11621198
patch:
11631199
summary: Update a subscription for a saved search

0 commit comments

Comments
 (0)