Skip to content
This repository was archived by the owner on Aug 28, 2025. It is now read-only.

Commit 837489d

Browse files
authored
feat: sortyBy (#153)
* feat: added SortBy feature with support for String, Int, Float and Bool On-behalf-of: @SAP a.shcherbatiuk@sap.com Signed-off-by: Artem Shcherbatiuk <vertex451@gmail.com>
1 parent 8017d86 commit 837489d

22 files changed

+832
-260
lines changed

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ curl \
206206
-H "Accept: text/event-stream" \
207207
-H "Content-Type: application/json" \
208208
-H "Authorization: 7f41d4ea-6809-4714-b345-f9281981b2dd" \
209-
-d '{"query": "subscription { core_openmfp_io_account(name: \"root-account\", namespace: \"default\") { spec { displayName }}}"}' \
209+
-d '{"query": "subscription { core_openmfp_org_account(name: \"root-account\") { spec { displayName }}}"}' \
210210
http://localhost:8080/root/graphql
211211
```
212212
Fields that will be listened are defined in the graphql query within the `{}` brackets.
@@ -219,7 +219,7 @@ curl \
219219
-H "Accept: text/event-stream" \
220220
-H "Content-Type: application/json" \
221221
-H "Authorization: 7f41d4ea-6809-4714-b345-f9281981b2dd" \
222-
-d '{"query": "subscription { core_openmfp_io_account(name: \"root-account\", namespace: \"default\", subscribeToAll: true) { metadata { name } }}"}' \
222+
-d '{"query": "subscription { core_openmfp_org_account(name: \"root-account\", subscribeToAll: true) { metadata { name } }}"}' \
223223
http://localhost:8080/root/graphql
224224
```
225225
P.S. Note, that only fields specified in `{}` brackets will be returned.
@@ -230,7 +230,7 @@ curl \
230230
-H "Accept: text/event-stream" \
231231
-H "Content-Type: application/json" \
232232
-H "Authorization: 7f41d4ea-6809-4714-b345-f9281981b2dd" \
233-
-d '{"query": "subscription { core_openmfp_io_accounts(namespace: \"default\") { spec { displayName }}}"}' \
233+
-d '{"query": "subscription { core_openmfp_org_accounts { spec { displayName }}}"}' \
234234
http://localhost:8080/root/graphql
235235
```
236236

Taskfile.yml

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ tasks:
1616
internal: true
1717
cmds:
1818
- test -s {{.LOCAL_BIN}}/setup-envtest || GOBIN=$(pwd)/{{.LOCAL_BIN}} go install sigs.k8s.io/controller-runtime/tools/setup-envtest@{{.ENVTEST_VERSION}}
19+
update:crd:
20+
desc: "Download the latest CRD from OpenMFP"
21+
cmds:
22+
- mkdir -p tests/gateway_test/testdata/crd
23+
- curl -sSLo tests/gateway_test/testdata/crd/core.openmfp.org_accounts.yaml https://raw.githubusercontent.com/openmfp/account-operator/main/config/crd/core.openmfp.org_accounts.yaml
24+
- echo "CRD updated successfully."
1925
setup:golangci-lint:
2026
internal: true
2127
cmds:
@@ -50,24 +56,30 @@ tasks:
5056
cmds:
5157
- go test ./... {{.ADDITIONAL_COMMAND_ARGS}}
5258
test:
53-
deps: [setup:envtest]
59+
deps: [setup:envtest, update:crd]
5460
cmds:
5561
- task: envtest
5662
vars:
5763
ADDITIONAL_COMMAND_ARGS: -coverprofile=./cover.out -covermode=atomic -coverpkg=./...
5864
cover:
59-
deps: [setup:envtest, setup:go-test-coverage]
65+
deps: [ setup:envtest, setup:go-test-coverage ]
6066
cmds:
6167
- task: envtest
6268
vars:
6369
ADDITIONAL_COMMAND_ARGS: -coverprofile=./cover.out -covermode=atomic -coverpkg=./...
6470
- "{{.LOCAL_BIN}}/go-test-coverage --profile cover.out --config ./.testcoverage.yml"
71+
cover-html:
72+
desc: "Generate and open HTML coverage report"
73+
deps: [cover]
74+
cmds:
75+
- go tool cover -html=cover.out -o coverage.html
76+
- open coverage.html || xdg-open coverage.html || start coverage.html
6577
validate:
6678
cmds:
6779
- task: mockery
6880
- task: lint
6981
- task: test
70-
82+
7183
# start uses deprecated gateway
7284
start:
7385
cmds:

gateway/resolver/arguments.go

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ package resolver
22

33
import (
44
"errors"
5+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
56
"maps"
7+
"strings"
68

79
"github.com/graphql-go/graphql"
810
"github.com/rs/zerolog/log"
@@ -15,6 +17,7 @@ const (
1517
NamespaceArg = "namespace"
1618
ObjectArg = "object"
1719
SubscribeToAllArg = "subscribeToAll"
20+
SortByArg = "sortBy"
1821
)
1922

2023
// FieldConfigArgumentsBuilder helps construct GraphQL field config arguments
@@ -29,15 +32,15 @@ func NewFieldConfigArguments() *FieldConfigArgumentsBuilder {
2932
}
3033
}
3134

32-
func (b *FieldConfigArgumentsBuilder) WithNameArg() *FieldConfigArgumentsBuilder {
35+
func (b *FieldConfigArgumentsBuilder) WithName() *FieldConfigArgumentsBuilder {
3336
b.arguments[NameArg] = &graphql.ArgumentConfig{
3437
Type: graphql.NewNonNull(graphql.String),
3538
Description: "The name of the object",
3639
}
3740
return b
3841
}
3942

40-
func (b *FieldConfigArgumentsBuilder) WithNamespaceArg() *FieldConfigArgumentsBuilder {
43+
func (b *FieldConfigArgumentsBuilder) WithNamespace() *FieldConfigArgumentsBuilder {
4144
b.arguments[NamespaceArg] = &graphql.ArgumentConfig{
4245
Type: graphql.String,
4346
Description: "The namespace in which to search for the objects",
@@ -46,23 +49,23 @@ func (b *FieldConfigArgumentsBuilder) WithNamespaceArg() *FieldConfigArgumentsBu
4649
return b
4750
}
4851

49-
func (b *FieldConfigArgumentsBuilder) WithLabelSelectorArg() *FieldConfigArgumentsBuilder {
52+
func (b *FieldConfigArgumentsBuilder) WithLabelSelector() *FieldConfigArgumentsBuilder {
5053
b.arguments[LabelSelectorArg] = &graphql.ArgumentConfig{
5154
Type: graphql.String,
5255
Description: "A label selector to filter the objects by",
5356
}
5457
return b
5558
}
5659

57-
func (b *FieldConfigArgumentsBuilder) WithObjectArg(resourceInputType *graphql.InputObject) *FieldConfigArgumentsBuilder {
60+
func (b *FieldConfigArgumentsBuilder) WithObject(resourceInputType *graphql.InputObject) *FieldConfigArgumentsBuilder {
5861
b.arguments[ObjectArg] = &graphql.ArgumentConfig{
5962
Type: graphql.NewNonNull(resourceInputType),
6063
Description: "The object to create or update",
6164
}
6265
return b
6366
}
6467

65-
func (b *FieldConfigArgumentsBuilder) WithSubscribeToAllArg() *FieldConfigArgumentsBuilder {
68+
func (b *FieldConfigArgumentsBuilder) WithSubscribeToAll() *FieldConfigArgumentsBuilder {
6669
b.arguments[SubscribeToAllArg] = &graphql.ArgumentConfig{
6770
Type: graphql.Boolean,
6871
DefaultValue: false,
@@ -71,6 +74,15 @@ func (b *FieldConfigArgumentsBuilder) WithSubscribeToAllArg() *FieldConfigArgume
7174
return b
7275
}
7376

77+
func (b *FieldConfigArgumentsBuilder) WithSortBy() *FieldConfigArgumentsBuilder {
78+
b.arguments[SortByArg] = &graphql.ArgumentConfig{
79+
Type: graphql.String,
80+
Description: "The field to sort the results by",
81+
DefaultValue: "metadata.name",
82+
}
83+
return b
84+
}
85+
7486
// Complete returns the constructed arguments and dereferences the builder
7587
func (b *FieldConfigArgumentsBuilder) Complete() graphql.FieldConfigArgument {
7688
return maps.Clone(b.arguments)
@@ -129,3 +141,22 @@ func getBoolArg(args map[string]interface{}, key string, required bool) (bool, e
129141
func isResourceNamespaceScoped(resourceScope apiextensionsv1.ResourceScope) bool {
130142
return resourceScope == apiextensionsv1.NamespaceScoped
131143
}
144+
145+
func validateSortBy(items []unstructured.Unstructured, fieldPath string) error {
146+
if len(items) == 0 {
147+
return nil // No items to validate against, assume valid
148+
}
149+
150+
sample := items[0]
151+
segments := strings.Split(fieldPath, ".")
152+
153+
_, found, err := unstructured.NestedFieldNoCopy(sample.Object, segments...)
154+
if !found {
155+
return errors.New("specified sortBy field does not exist")
156+
}
157+
if err != nil {
158+
return errors.Join(errors.New("error accessing specified sortBy field"), err)
159+
}
160+
161+
return nil
162+
}

gateway/resolver/arguments_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package resolver_test
2+
3+
import (
4+
"errors"
5+
"github.com/openmfp/kubernetes-graphql-gateway/gateway/resolver"
6+
"github.com/stretchr/testify/assert"
7+
"testing"
8+
)
9+
10+
func TestGetStrArg(t *testing.T) {
11+
tests := []struct {
12+
name string
13+
args map[string]interface{}
14+
error error
15+
}{
16+
{
17+
name: "invalid_type_ERROR",
18+
args: map[string]interface{}{
19+
"arg1": false,
20+
},
21+
error: errors.New("invalid type for argument: arg1"),
22+
},
23+
{
24+
name: "empty_value_ERROR",
25+
args: map[string]interface{}{
26+
"arg1": "",
27+
},
28+
error: errors.New("empty value for argument: arg1"),
29+
},
30+
}
31+
32+
for _, tt := range tests {
33+
t.Run(tt.name, func(t *testing.T) {
34+
_, err := resolver.GetStringArg(tt.args, "arg1", true)
35+
if tt.error != nil {
36+
assert.EqualError(t, err, tt.error.Error())
37+
}
38+
})
39+
}
40+
}
41+
42+
func TestGetBoolArg(t *testing.T) {
43+
tests := []struct {
44+
name string
45+
args map[string]interface{}
46+
error error
47+
}{
48+
{
49+
name: "missing_required_argument_ERROR",
50+
args: map[string]interface{}{},
51+
error: errors.New("missing required argument: arg1"),
52+
},
53+
{
54+
name: "invalid_type_ERROR",
55+
args: map[string]interface{}{
56+
"arg1": "MUST_BE_BOOL",
57+
},
58+
error: errors.New("invalid type for argument: arg1"),
59+
},
60+
}
61+
62+
for _, tt := range tests {
63+
t.Run(tt.name, func(t *testing.T) {
64+
_, err := resolver.GetBoolArg(tt.args, "arg1", true)
65+
if tt.error != nil {
66+
assert.EqualError(t, err, tt.error.Error())
67+
}
68+
})
69+
}
70+
}

gateway/resolver/export_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package resolver
22

3+
import "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
4+
35
func (r *Service) GetOriginalGroupName(key string) string {
46
return r.getOriginalGroupName(key)
57
}
@@ -11,3 +13,15 @@ func (r *Service) GetGroupName(key string) string {
1113
func (r *Service) SetGroupNames(names map[string]string) {
1214
r.groupNames = names
1315
}
16+
17+
func GetStringArg(args map[string]interface{}, key string, required bool) (string, error) {
18+
return getStringArg(args, key, required)
19+
}
20+
21+
func GetBoolArg(args map[string]interface{}, key string, required bool) (bool, error) {
22+
return getBoolArg(args, key, required)
23+
}
24+
25+
func CompareUnstructured(a, b unstructured.Unstructured, fieldPath string) int {
26+
return compareUnstructured(a, b, fieldPath)
27+
}

gateway/resolver/resolver.go

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88
"gopkg.in/yaml.v3"
99
v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
1010
"regexp"
11+
"sort"
12+
"strings"
1113

1214
"github.com/graphql-go/graphql"
1315
"go.opentelemetry.io/otel"
@@ -109,6 +111,21 @@ func (r *Service) ListItems(gvk schema.GroupVersionKind, scope v1.ResourceScope)
109111
return nil, err
110112
}
111113

114+
sortBy, err := getStringArg(p.Args, SortByArg, false)
115+
if err != nil {
116+
return nil, err
117+
}
118+
119+
err = validateSortBy(list.Items, sortBy)
120+
if err != nil {
121+
log.Error().Err(err).Str(SortByArg, sortBy).Msg("Invalid sortBy field path")
122+
return nil, err
123+
}
124+
125+
sort.Slice(list.Items, func(i, j int) bool {
126+
return compareUnstructured(list.Items[i], list.Items[j], sortBy) < 0
127+
})
128+
112129
items := make([]map[string]any, len(list.Items))
113130
for i, item := range list.Items {
114131
items[i] = item.Object
@@ -183,7 +200,7 @@ func (r *Service) GetItemAsYAML(gvk schema.GroupVersionKind, scope v1.ResourceSc
183200
}
184201

185202
var returnYaml bytes.Buffer
186-
if err := yaml.NewEncoder(&returnYaml).Encode(out); err != nil {
203+
if err = yaml.NewEncoder(&returnYaml).Encode(out); err != nil {
187204
return "", err
188205
}
189206

@@ -351,3 +368,63 @@ func (r *Service) getOriginalGroupName(groupName string) string {
351368

352369
return groupName
353370
}
371+
372+
func compareUnstructured(a, b unstructured.Unstructured, fieldPath string) int {
373+
segments := strings.Split(fieldPath, ".")
374+
375+
aVal, foundA, errA := unstructured.NestedFieldNoCopy(a.Object, segments...)
376+
bVal, foundB, errB := unstructured.NestedFieldNoCopy(b.Object, segments...)
377+
if errA != nil || errB != nil || !foundA || !foundB {
378+
return 0 // fallback if fields are missing or inaccessible
379+
}
380+
381+
switch av := aVal.(type) {
382+
case string:
383+
if bv, ok := bVal.(string); ok {
384+
return strings.Compare(av, bv)
385+
}
386+
case int64:
387+
if bv, ok := bVal.(int64); ok {
388+
return compareNumbers(av, bv)
389+
}
390+
case int32:
391+
if bv, ok := bVal.(int32); ok {
392+
return compareNumbers(int64(av), int64(bv))
393+
} else if bv, ok := bVal.(int64); ok {
394+
return compareNumbers(int64(av), bv)
395+
}
396+
case float64:
397+
if bv, ok := bVal.(float64); ok {
398+
return compareNumbers(av, bv)
399+
}
400+
case float32:
401+
if bv, ok := bVal.(float32); ok {
402+
return compareNumbers(float64(av), float64(bv))
403+
} else if bv, ok := bVal.(float64); ok {
404+
return compareNumbers(float64(av), bv)
405+
}
406+
case bool:
407+
if bv, ok := bVal.(bool); ok {
408+
switch {
409+
case av && !bv:
410+
return -1
411+
case !av && bv:
412+
return 1
413+
default:
414+
return 0
415+
}
416+
}
417+
}
418+
return 0 // unhandled or non-comparable types
419+
}
420+
421+
func compareNumbers[T int64 | float64](a, b T) int {
422+
switch {
423+
case a < b:
424+
return -1
425+
case a > b:
426+
return 1
427+
default:
428+
return 0
429+
}
430+
}

0 commit comments

Comments
 (0)