Skip to content

Commit 1f9a727

Browse files
Merge pull request #4416 from jingyih/resource-speech-phraseset
feat: add controller, test and mock support for SpeechPhraseSet
2 parents a43b2a2 + 71c0436 commit 1f9a727

File tree

7 files changed

+906
-0
lines changed

7 files changed

+906
-0
lines changed

config/tests/samples/create/harness.go

+1
Original file line numberDiff line numberDiff line change
@@ -1017,6 +1017,7 @@ func MaybeSkip(t *testing.T, name string, resources []*unstructured.Unstructured
10171017
case schema.GroupKind{Group: "recaptchaenterprise.cnrm.cloud.google.com", Kind: "ReCAPTCHAEnterpriseFirewallPolicy"}:
10181018

10191019
case schema.GroupKind{Group: "speech.cnrm.cloud.google.com", Kind: "SpeechCustomClass"}:
1020+
case schema.GroupKind{Group: "speech.cnrm.cloud.google.com", Kind: "SpeechPhraseSet"}:
10201021

10211022
default:
10221023
t.Skipf("gk %v not suppported by mock gcp %v; skipping", gvk.GroupKind(), name)

mockgcp/mockspeech/phraseset.go

+280
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
// Copyright 2024 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+
// +tool:mockgcp-support
16+
// proto.service: google.cloud.speech.v2.Speech
17+
// proto.message: google.cloud.speech.v2.PhraseSet
18+
19+
package mockspeech
20+
21+
import (
22+
"context"
23+
"fmt"
24+
"strconv"
25+
"strings"
26+
"time"
27+
28+
"google.golang.org/grpc/codes"
29+
"google.golang.org/grpc/status"
30+
"google.golang.org/protobuf/proto"
31+
"google.golang.org/protobuf/types/known/timestamppb"
32+
33+
"github.com/GoogleCloudPlatform/k8s-config-connector/mockgcp/common/fields"
34+
"github.com/GoogleCloudPlatform/k8s-config-connector/mockgcp/common/projects"
35+
pb "github.com/GoogleCloudPlatform/k8s-config-connector/mockgcp/generated/mockgcp/cloud/speech/v2"
36+
"github.com/google/uuid"
37+
longrunningpb "google.golang.org/genproto/googleapis/longrunning"
38+
)
39+
40+
func (s *SpeechV2) GetPhraseSet(ctx context.Context, req *pb.GetPhraseSetRequest) (*pb.PhraseSet, error) {
41+
name, err := s.parsePhraseSetName(req.GetName())
42+
if err != nil {
43+
return nil, err
44+
}
45+
fqn := name.String()
46+
47+
obj := &pb.PhraseSet{}
48+
if err := s.storage.Get(ctx, fqn, obj); err != nil {
49+
if status.Code(err) == codes.NotFound {
50+
// Adjusted error message based on typical Google API responses
51+
return nil, status.Errorf(codes.NotFound, "Unable to find PhraseSet %s from project %d.", name.PhraseSetID, name.Project.Number)
52+
}
53+
return nil, err
54+
}
55+
56+
return obj, nil
57+
}
58+
59+
func (s *SpeechV2) CreatePhraseSet(ctx context.Context, req *pb.CreatePhraseSetRequest) (*longrunningpb.Operation, error) {
60+
reqName := fmt.Sprintf("%s/phraseSets/%s", req.GetParent(), req.GetPhraseSetId())
61+
name, err := s.parsePhraseSetName(reqName)
62+
if err != nil {
63+
return nil, err
64+
}
65+
fqn := name.String()
66+
now := time.Now()
67+
68+
obj := proto.Clone(req.GetPhraseSet()).(*pb.PhraseSet)
69+
obj.Name = fqn
70+
obj.CreateTime = timestamppb.New(now)
71+
obj.UpdateTime = timestamppb.New(now)
72+
obj.Uid = uuid.New().String()
73+
obj.State = pb.PhraseSet_ACTIVE // Assume immediate activation for mock
74+
obj.Etag = fields.ComputeWeakEtag(obj)
75+
76+
// Validate boost values
77+
if obj.Boost < 0 || obj.Boost > 20 {
78+
return nil, status.Errorf(codes.InvalidArgument, "phrase set boost %v must be between 0 and 20", obj.Boost)
79+
}
80+
for _, phrase := range obj.Phrases {
81+
if phrase.Boost < 0 || phrase.Boost > 20 {
82+
return nil, status.Errorf(codes.InvalidArgument, "phrase boost %v for phrase '%s' must be between 0 and 20", phrase.Boost, phrase.Value)
83+
}
84+
}
85+
86+
if err := s.storage.Create(ctx, fqn, obj); err != nil {
87+
return nil, err
88+
}
89+
90+
metadata := &pb.OperationMetadata{
91+
CreateTime: timestamppb.New(now),
92+
UpdateTime: timestamppb.New(now),
93+
Method: "google.cloud.speech.v2.Speech.CreatePhraseSet",
94+
ProgressPercent: 100,
95+
}
96+
97+
// change project ID to project number
98+
metadata.Resource = strings.Replace(obj.GetName(), "projects/"+name.Project.ID, "projects/"+strconv.FormatInt(name.Project.Number, 10), 1)
99+
100+
// change project ID to project number in request details
101+
req.Parent = strings.Replace(req.GetParent(), "projects/"+name.Project.ID, "projects/"+strconv.FormatInt(name.Project.Number, 10), 1)
102+
metadata.Request = &pb.OperationMetadata_CreatePhraseSetRequest{
103+
CreatePhraseSetRequest: req,
104+
}
105+
106+
prefix := fmt.Sprintf("projects/%d/locations/%s", name.Project.Number, name.Location)
107+
108+
return s.operations.DoneLRO(ctx, prefix, metadata, obj)
109+
}
110+
111+
func (s *SpeechV2) UpdatePhraseSet(ctx context.Context, req *pb.UpdatePhraseSetRequest) (*longrunningpb.Operation, error) {
112+
name, err := s.parsePhraseSetName(req.GetPhraseSet().GetName())
113+
if err != nil {
114+
return nil, err
115+
}
116+
fqn := name.String()
117+
now := time.Now()
118+
119+
obj := &pb.PhraseSet{}
120+
if err := s.storage.Get(ctx, fqn, obj); err != nil {
121+
return nil, err
122+
}
123+
124+
paths := req.GetUpdateMask().GetPaths()
125+
if len(paths) == 0 {
126+
return nil, status.Errorf(codes.InvalidArgument, "update_mask must be provided")
127+
}
128+
129+
for i, path := range paths {
130+
switch path {
131+
case "displayName": // proto field name is display_name
132+
obj.DisplayName = req.GetPhraseSet().GetDisplayName()
133+
// HACK: to make the field mask valid when returning
134+
req.UpdateMask.Paths[i] = "display_name"
135+
case "phrases":
136+
obj.Phrases = req.GetPhraseSet().GetPhrases()
137+
case "boost":
138+
obj.Boost = req.GetPhraseSet().GetBoost()
139+
case "annotations":
140+
obj.Annotations = req.GetPhraseSet().GetAnnotations()
141+
default:
142+
return nil, status.Errorf(codes.InvalidArgument, "update_mask path %q not valid for PhraseSet update", path)
143+
}
144+
}
145+
146+
obj.UpdateTime = timestamppb.New(now)
147+
obj.Etag = fields.ComputeWeakEtag(obj)
148+
149+
if err := s.storage.Update(ctx, fqn, obj); err != nil {
150+
return nil, err
151+
}
152+
153+
metadata := &pb.OperationMetadata{
154+
CreateTime: timestamppb.New(now),
155+
UpdateTime: timestamppb.New(now),
156+
Method: "google.cloud.speech.v2.Speech.UpdatePhraseSet",
157+
ProgressPercent: 100,
158+
}
159+
160+
// change project ID to project number
161+
metadata.Resource = strings.Replace(obj.GetName(), "projects/"+name.Project.ID, "projects/"+strconv.FormatInt(name.Project.Number, 10), 1)
162+
163+
// change project ID to project number in request details
164+
req.PhraseSet.Name = strings.Replace(req.PhraseSet.GetName(), "projects/"+name.Project.ID, "projects/"+strconv.FormatInt(name.Project.Number, 10), 1)
165+
metadata.Request = &pb.OperationMetadata_UpdatePhraseSetRequest{
166+
UpdatePhraseSetRequest: req,
167+
}
168+
169+
prefix := fmt.Sprintf("projects/%d/locations/%s", name.Project.Number, name.Location)
170+
171+
return s.operations.DoneLRO(ctx, prefix, metadata, obj)
172+
}
173+
174+
func (s *SpeechV2) DeletePhraseSet(ctx context.Context, req *pb.DeletePhraseSetRequest) (*longrunningpb.Operation, error) {
175+
name, err := s.parsePhraseSetName(req.GetName())
176+
if err != nil {
177+
return nil, err
178+
}
179+
fqn := name.String()
180+
now := time.Now()
181+
182+
prefix := fmt.Sprintf("projects/%d/locations/%s", name.Project.Number, name.Location)
183+
184+
// change project ID to project number in request details
185+
req.Name = strings.Replace(req.GetName(), "projects/"+name.Project.ID, "projects/"+strconv.FormatInt(name.Project.Number, 10), 1)
186+
187+
obj := &pb.PhraseSet{}
188+
if err := s.storage.Get(ctx, fqn, obj); err != nil {
189+
if status.Code(err) == codes.NotFound {
190+
if req.GetAllowMissing() {
191+
// Return a completed LRO indicating success (no-op)
192+
metadata := &pb.OperationMetadata{
193+
CreateTime: timestamppb.New(now),
194+
UpdateTime: timestamppb.New(now),
195+
Resource: strings.Replace(fqn, "projects/"+name.Project.ID, "projects/"+strconv.FormatInt(name.Project.Number, 10), 1), // Use project number in metadata
196+
Method: "google.cloud.speech.v2.Speech.DeletePhraseSet",
197+
ProgressPercent: 100,
198+
Request: &pb.OperationMetadata_DeletePhraseSetRequest{
199+
DeletePhraseSetRequest: req,
200+
},
201+
}
202+
// Return a placeholder object matching the LRO response type
203+
deletedPlaceholder := &pb.PhraseSet{
204+
Name: strings.Replace(fqn, "projects/"+name.Project.ID, "projects/"+strconv.FormatInt(name.Project.Number, 10), 1),
205+
State: pb.PhraseSet_DELETED,
206+
}
207+
return s.operations.DoneLRO(ctx, prefix, metadata, deletedPlaceholder)
208+
}
209+
return nil, status.Errorf(codes.NotFound, "PhraseSet %q was not found.", name.PhraseSetID)
210+
}
211+
return nil, err
212+
}
213+
214+
// Validate Etag if provided
215+
if req.GetEtag() != "" && req.GetEtag() != obj.Etag {
216+
return nil, status.Errorf(codes.Aborted, "etag mismatch for PhraseSet %q", name.PhraseSetID)
217+
}
218+
219+
// Mark as deleted conceptually (although we delete immediately)
220+
obj.State = pb.PhraseSet_DELETED
221+
obj.DeleteTime = timestamppb.New(now)
222+
// Set expire time, e.g., 30 days from now, though it won't be used if we delete immediately
223+
obj.ExpireTime = timestamppb.New(now.Add(30 * 24 * time.Hour))
224+
225+
// Delete from storage
226+
if err := s.storage.Delete(ctx, fqn, &pb.PhraseSet{}); err != nil {
227+
return nil, status.Errorf(codes.Internal, "failed to delete PhraseSet %q: %v", fqn, err)
228+
}
229+
230+
metadata := &pb.OperationMetadata{
231+
CreateTime: timestamppb.New(now),
232+
UpdateTime: timestamppb.New(now),
233+
Method: "google.cloud.speech.v2.Speech.DeletePhraseSet",
234+
ProgressPercent: 100,
235+
Request: &pb.OperationMetadata_DeletePhraseSetRequest{
236+
DeletePhraseSetRequest: req,
237+
},
238+
Resource: obj.GetName(),
239+
}
240+
241+
return s.operations.DoneLRO(ctx, prefix, metadata, obj)
242+
}
243+
244+
type phraseSetName struct {
245+
Project *projects.ProjectData
246+
Location string
247+
PhraseSetID string
248+
}
249+
250+
func (n *phraseSetName) String() string {
251+
return fmt.Sprintf("projects/%d/locations/%s/phraseSets/%s", n.Project.Number, n.Location, n.PhraseSetID)
252+
}
253+
254+
// parsePhraseSetName parses a string into a phraseSetName.
255+
// The expected form is `projects/*/locations/*/phraseSets/*`.
256+
func (s *MockService) parsePhraseSetName(name string) (*phraseSetName, error) {
257+
tokens := strings.Split(name, "/")
258+
259+
if len(tokens) == 6 && tokens[0] == "projects" && tokens[2] == "locations" && tokens[4] == "phraseSets" {
260+
project, err := s.Projects.GetProjectByID(tokens[1])
261+
if err != nil {
262+
return nil, status.Errorf(codes.InvalidArgument, "project %q not found: %v", tokens[1], err)
263+
}
264+
265+
nameObj := &phraseSetName{
266+
Project: project,
267+
Location: tokens[3],
268+
PhraseSetID: tokens[5],
269+
}
270+
271+
// Basic validation for IDs - should not be empty
272+
if nameObj.Location == "" || nameObj.PhraseSetID == "" {
273+
return nil, status.Errorf(codes.InvalidArgument, "name %q has empty location or phrase set ID", name)
274+
}
275+
276+
return nameObj, nil
277+
}
278+
279+
return nil, status.Errorf(codes.InvalidArgument, "name %q is not in the expected format projects/*/locations/*/phraseSets/*", name)
280+
}

0 commit comments

Comments
 (0)