Skip to content

Commit 6227cde

Browse files
authored
Added support for resource QdrantEntity (#92)
* Added support for resource QdrantEntity * Formatted file (make gen) * Fixed typo * Use camelCase, remove Hashes (implementation detail) * Added unit tests for apiextensionsJSONToStructpb and structpbToApiextensionsJSON
1 parent d1e11b3 commit 6227cde

File tree

6 files changed

+803
-0
lines changed

6 files changed

+803
-0
lines changed

api/v1/qdrantentity_types.go

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
package v1
2+
3+
import (
4+
"fmt"
5+
6+
"google.golang.org/protobuf/types/known/structpb"
7+
apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
8+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
9+
"k8s.io/apimachinery/pkg/util/json"
10+
)
11+
12+
//goland:noinspection GoUnusedConst
13+
const (
14+
KindQdrantEntity = "QdrantEntity"
15+
ResourceQdrantEntity = "qdrantentities"
16+
)
17+
18+
// QdrantEntitySpec defines the desired state of QdrantEntity
19+
type QdrantEntitySpec struct {
20+
// The unique identifier of the entity (in UUID format).
21+
Id string `json:"id,omitempty"`
22+
// The type of the entity.
23+
EntityType string `json:"entityType,omitempty"`
24+
// Timestamp when the entity was created.
25+
CreatedAt metav1.Time `json:"createdAt,omitempty"`
26+
// Timestamp when the entity was last updated.
27+
LastUpdatedAt metav1.Time `json:"lastUpdatedAt,omitempty"`
28+
// Timestamp when the entity was deleted (or is started to be deleting).
29+
// If not set the entity is not deleted
30+
DeletedAt metav1.Time `json:"deletedAt,omitempty"`
31+
// Generic payload for this entity
32+
Payload apiextensions.JSON `json:"payload,omitempty"`
33+
}
34+
35+
// GetPayloadForGRPC gets the current payload
36+
func (r QdrantEntitySpec) GetPayloadForGRPC() (*structpb.Struct, error) {
37+
return apiextensionsJSONToStructpb(r.Payload)
38+
}
39+
40+
// SetPayloadFromGRPC sets the current payload
41+
func (r *QdrantEntitySpec) SetPayloadFromGRPC(payload *structpb.Struct) error {
42+
if r == nil {
43+
return nil
44+
}
45+
jsonPayload, err := structpbToApiextensionsJSON(payload)
46+
if err != nil {
47+
return err
48+
}
49+
r.Payload = jsonPayload
50+
return nil
51+
}
52+
53+
type EntityPhase string
54+
55+
//goland:noinspection GoUnusedConst
56+
const (
57+
EntityPhaseCreating EntityPhase = "Creating"
58+
EntityPhaseReady EntityPhase = "Ready"
59+
EntityPhaseFailing EntityPhase = "Failing"
60+
EntityPhaseDeleting EntityPhase = "Deleting"
61+
EntityPhaseDeleted EntityPhase = "Deleted"
62+
)
63+
64+
// QdrantEntitySpecStatus defines the observed state of QdrantEntitySpec
65+
// +kubebuilder:pruning:PreserveUnknownFields
66+
67+
type QdrantEntityStatus struct {
68+
// Phase is the current phase of the entity
69+
// +kubebuilder:validation:Enum=Creating;Ready;Failing;Deleting;Deleted
70+
Phase EntityPhase `json:"phase,omitempty"`
71+
// Result is the last result from the invocation to a manager
72+
Result QdrantEntityStatusResult `json:"result,omitempty"`
73+
}
74+
75+
// EntityResult is the last result from the invocation to a manager
76+
type EntityResult string
77+
78+
//goland:noinspection GoUnusedConst
79+
const (
80+
EntityResultOk EntityResult = "Ok"
81+
EntityRersultPending EntityResult = "Pending"
82+
EntityResultError EntityResult = "Error"
83+
)
84+
85+
// QdrantEntityStatusResult is the last result from the invocation to a manager
86+
type QdrantEntityStatusResult struct {
87+
// The result of last reconcile of the entity
88+
// +kubebuilder:validation:Enum=Ok;Pending;Error
89+
Result EntityResult `json:"result,omitempty"`
90+
// The reason of the result (e.g. in case of an error)
91+
Reason string `json:"reason,omitempty"`
92+
// The optional payload of the status.
93+
Payload apiextensions.JSON `json:"payload,omitempty"`
94+
}
95+
96+
// GetPayloadForGRPC gets the current payload
97+
func (r QdrantEntityStatusResult) GetPayloadForGRPC() (*structpb.Struct, error) {
98+
return apiextensionsJSONToStructpb(r.Payload)
99+
}
100+
101+
// SetPayloadFromGRPC sets the current payload
102+
func (r *QdrantEntityStatusResult) SetPayloadFromGRPC(payload *structpb.Struct) error {
103+
if r == nil {
104+
return nil
105+
}
106+
jsonPayload, err := structpbToApiextensionsJSON(payload)
107+
if err != nil {
108+
return err
109+
}
110+
r.Payload = jsonPayload
111+
return nil
112+
}
113+
114+
// +kubebuilder:object:root=true
115+
// +kubebuilder:subresource:status
116+
// +kubebuilder:resource:path=qdrantentities,singular=qdrantentity,shortName=qe
117+
// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase`
118+
// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
119+
120+
// QdrantEntity is the Schema for the qdrantentities API
121+
type QdrantEntity struct {
122+
metav1.TypeMeta `json:",inline"`
123+
metav1.ObjectMeta `json:"metadata,omitempty"`
124+
125+
Spec QdrantEntitySpec `json:"spec,omitempty"`
126+
Status QdrantEntityStatus `json:"status,omitempty"`
127+
}
128+
129+
//+kubebuilder:object:root=true
130+
131+
// QdrantEntityList contains a list of QdrantEntity objects
132+
type QdrantEntityList struct {
133+
metav1.TypeMeta `json:",inline"`
134+
metav1.ListMeta `json:"metadata,omitempty"`
135+
Items []QdrantEntity `json:"items"`
136+
}
137+
138+
func init() {
139+
SchemeBuilder.Register(&QdrantEntity{}, &QdrantEntityList{})
140+
}
141+
142+
// apiextensionsJSONToStructpb converts apiextensions.JSON to *structpb.Struct.
143+
func apiextensionsJSONToStructpb(in apiextensions.JSON) (*structpb.Struct, error) {
144+
// Handle empty json
145+
if len(in.Raw) == 0 {
146+
return &structpb.Struct{Fields: map[string]*structpb.Value{}}, nil
147+
}
148+
// Unmarshal the provided JSON into a data struct
149+
var data map[string]interface{}
150+
if err := json.Unmarshal(in.Raw, &data); err != nil {
151+
return nil, fmt.Errorf("failed to unmarshal apiextensions.JSON: %w", err)
152+
}
153+
// Convert the data into a Struct
154+
result, err := structpb.NewStruct(data)
155+
if err != nil {
156+
return nil, fmt.Errorf("failed to create *structpb.Struct: %w", err)
157+
}
158+
// Return the Struct
159+
return result, nil
160+
}
161+
162+
// structpbToApiextensionsJSON converts *structpb.Struct to apiextensions.JSON.
163+
func structpbToApiextensionsJSON(in *structpb.Struct) (apiextensions.JSON, error) {
164+
// Handle empty struct
165+
if in == nil {
166+
return apiextensions.JSON{Raw: []byte{}}, nil
167+
}
168+
// Convert the Struct into a data struct
169+
data := in.AsMap()
170+
// Marshal the data into a JSON
171+
jsonData, err := json.Marshal(data)
172+
if err != nil {
173+
return apiextensions.JSON{}, fmt.Errorf("failed to marshal to JSON: %w", err)
174+
}
175+
// Return the JSON (with the raw Json data)
176+
return apiextensions.JSON{Raw: jsonData}, nil
177+
}

api/v1/qdrantentity_types_test.go

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
package v1
2+
3+
import (
4+
"testing"
5+
6+
"github.com/google/go-cmp/cmp"
7+
"github.com/google/go-cmp/cmp/cmpopts"
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
"google.golang.org/protobuf/types/known/structpb"
11+
apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
12+
)
13+
14+
// Test apiextensionsJSONToStructpb
15+
func TestApiextensionsJSONToStructpb(t *testing.T) {
16+
testCases := []struct {
17+
name string
18+
input apiextensions.JSON
19+
expected *structpb.Struct
20+
expectedError string
21+
}{
22+
{
23+
name: "valid JSON",
24+
input: apiextensions.JSON{
25+
Raw: []byte(`{"name": "John Doe", "age": 30}`),
26+
},
27+
expected: &structpb.Struct{
28+
Fields: map[string]*structpb.Value{
29+
"name": {Kind: &structpb.Value_StringValue{StringValue: "John Doe"}},
30+
"age": {Kind: &structpb.Value_NumberValue{NumberValue: 30}},
31+
},
32+
},
33+
},
34+
{
35+
name: "valid JSON with nested struct",
36+
input: apiextensions.JSON{
37+
Raw: []byte(`{"name": "John Doe", "age": 30, "address": {"street": "mainstreet", "zip": "1234"}}`),
38+
},
39+
expected: &structpb.Struct{
40+
Fields: map[string]*structpb.Value{
41+
"name": {Kind: &structpb.Value_StringValue{StringValue: "John Doe"}},
42+
"age": {Kind: &structpb.Value_NumberValue{NumberValue: 30}},
43+
"address": {Kind: &structpb.Value_StructValue{
44+
StructValue: &structpb.Struct{
45+
Fields: map[string]*structpb.Value{
46+
"street": {Kind: &structpb.Value_StringValue{StringValue: "mainstreet"}},
47+
"zip": {Kind: &structpb.Value_StringValue{StringValue: "1234"}},
48+
},
49+
},
50+
}},
51+
},
52+
},
53+
},
54+
{
55+
name: "valid JSON with array",
56+
input: apiextensions.JSON{
57+
Raw: []byte(`{"name": "John Doe", "age": 30, "tags": ["tag1", "tag2"]}`),
58+
},
59+
expected: &structpb.Struct{
60+
Fields: map[string]*structpb.Value{
61+
"name": {Kind: &structpb.Value_StringValue{StringValue: "John Doe"}},
62+
"age": {Kind: &structpb.Value_NumberValue{NumberValue: 30}},
63+
"tags": {Kind: &structpb.Value_ListValue{
64+
ListValue: &structpb.ListValue{
65+
Values: []*structpb.Value{
66+
{Kind: &structpb.Value_StringValue{StringValue: "tag1"}},
67+
{Kind: &structpb.Value_StringValue{StringValue: "tag2"}},
68+
},
69+
},
70+
}},
71+
},
72+
},
73+
},
74+
{
75+
name: "invalid JSON",
76+
input: apiextensions.JSON{Raw: []byte(`{"name": "John Doe", "age": }`)},
77+
expected: nil,
78+
expectedError: "failed to unmarshal apiextensions.JSON",
79+
},
80+
{
81+
name: "empty JSON",
82+
input: apiextensions.JSON{Raw: []byte(``)},
83+
expected: &structpb.Struct{Fields: map[string]*structpb.Value{}},
84+
expectedError: "",
85+
},
86+
{
87+
name: "empty JSON-object",
88+
input: apiextensions.JSON{Raw: []byte(`{}`)},
89+
expected: &structpb.Struct{Fields: map[string]*structpb.Value{}},
90+
expectedError: "",
91+
},
92+
}
93+
94+
for _, tc := range testCases {
95+
// Run the individual test case (added the name to the runner)
96+
t.Run(tc.name, func(t *testing.T) {
97+
result, err := apiextensionsJSONToStructpb(tc.input)
98+
99+
if tc.expectedError != "" {
100+
require.Error(t, err)
101+
assert.Contains(t, err.Error(), tc.expectedError)
102+
return
103+
}
104+
require.NoError(t, err)
105+
106+
// We use this special method for comparing protobufs
107+
if diff := cmp.Diff(tc.expected, result, cmpopts.IgnoreUnexported(structpb.Struct{}, structpb.Value{}, structpb.ListValue{})); diff != "" {
108+
t.Errorf("apiextensionsJSONToStructpb() mismatch (-want +got):\n%s", diff)
109+
}
110+
})
111+
}
112+
}
113+
114+
// Test structpbToApiextensionsJSON
115+
func TestStructpbToApiextensionsJSON(t *testing.T) {
116+
testCases := []struct {
117+
name string
118+
input *structpb.Struct
119+
expected apiextensions.JSON
120+
expectedError string
121+
}{
122+
{
123+
name: "valid struct",
124+
input: &structpb.Struct{
125+
Fields: map[string]*structpb.Value{
126+
"name": {Kind: &structpb.Value_StringValue{StringValue: "John Doe"}},
127+
"age": {Kind: &structpb.Value_NumberValue{NumberValue: 30}},
128+
},
129+
},
130+
expected: apiextensions.JSON{Raw: []byte(`{"age":30,"name":"John Doe"}`)},
131+
},
132+
{
133+
name: "valid struct with nested struct",
134+
input: &structpb.Struct{
135+
Fields: map[string]*structpb.Value{
136+
"name": {Kind: &structpb.Value_StringValue{StringValue: "John Doe"}},
137+
"age": {Kind: &structpb.Value_NumberValue{NumberValue: 30}},
138+
"address": {Kind: &structpb.Value_StructValue{
139+
StructValue: &structpb.Struct{
140+
Fields: map[string]*structpb.Value{
141+
"street": {Kind: &structpb.Value_StringValue{StringValue: "mainstreet"}},
142+
"zip": {Kind: &structpb.Value_StringValue{StringValue: "1234"}},
143+
},
144+
},
145+
}},
146+
},
147+
},
148+
expected: apiextensions.JSON{Raw: []byte(`{"address":{"street":"mainstreet","zip":"1234"},"age":30,"name":"John Doe"}`)},
149+
},
150+
{
151+
name: "valid struct with array",
152+
input: &structpb.Struct{
153+
Fields: map[string]*structpb.Value{
154+
"name": {Kind: &structpb.Value_StringValue{StringValue: "John Doe"}},
155+
"age": {Kind: &structpb.Value_NumberValue{NumberValue: 30}},
156+
"tags": {Kind: &structpb.Value_ListValue{
157+
ListValue: &structpb.ListValue{
158+
Values: []*structpb.Value{
159+
{Kind: &structpb.Value_StringValue{StringValue: "tag1"}},
160+
{Kind: &structpb.Value_StringValue{StringValue: "tag2"}},
161+
},
162+
},
163+
}},
164+
},
165+
},
166+
expected: apiextensions.JSON{Raw: []byte(`{"age":30,"name":"John Doe","tags":["tag1","tag2"]}`)},
167+
},
168+
{
169+
name: "nil struct",
170+
input: nil,
171+
expected: apiextensions.JSON{Raw: []byte{}},
172+
expectedError: "",
173+
},
174+
}
175+
176+
for _, tc := range testCases {
177+
// Run the individual test case (added the name to the runner)
178+
t.Run(tc.name, func(t *testing.T) {
179+
result, err := structpbToApiextensionsJSON(tc.input)
180+
181+
if tc.expectedError != "" {
182+
require.Error(t, err)
183+
assert.Contains(t, err.Error(), tc.expectedError)
184+
return
185+
}
186+
require.NoError(t, err)
187+
assert.Equal(t, tc.expected, result)
188+
})
189+
}
190+
}

0 commit comments

Comments
 (0)