diff --git a/go.mod b/go.mod index 790504415..022ed9242 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,12 @@ module github.com/getprobo/probo go 1.23.4 require ( + gearno.de/ref v0.0.0-20221013162104-a522beda40f4 github.com/99designs/gqlgen v0.17.63 github.com/jackc/pgx/v5 v5.7.1 github.com/prometheus/client_golang v1.20.5 github.com/vektah/gqlparser/v2 v2.5.21 + go.gearno.de/crypto/uuid v0.1.0 go.gearno.de/kit v0.0.0-20241119134016-ee4f36699e6b go.opentelemetry.io/otel/trace v1.32.0 ) @@ -34,7 +36,6 @@ require ( github.com/sosodev/duration v1.3.1 // indirect github.com/urfave/cli/v2 v2.27.5 // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect - go.gearno.de/crypto/uuid v0.1.0 // indirect go.gearno.de/x/panicf v0.1.1 // indirect go.opentelemetry.io/otel v1.32.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0 // indirect diff --git a/go.sum b/go.sum index 705363abc..73db4c9d4 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +gearno.de/ref v0.0.0-20221013162104-a522beda40f4 h1:tS3oPI89+Y33vZRR/DwbNTewefycz1UzEeDYgMKNdzY= +gearno.de/ref v0.0.0-20221013162104-a522beda40f4/go.mod h1:yMxgb+Im8XTCLBBgpy8p5xE6/OaWkKyfypNd3HPQpLw= github.com/99designs/gqlgen v0.17.63 h1:HCdaYDPd9HqUXRchEvmE3EFzELRwLlaJ8DBuyC8Cqto= github.com/99designs/gqlgen v0.17.63/go.mod h1:sVCM2iwIZisJjTI/DEC3fpH+HFgxY1496ZJ+jbT9IjA= github.com/agnivade/levenshtein v1.2.0 h1:U9L4IOT0Y3i0TIlUIDJ7rVUziKi/zPbrJGaFrtYH3SY= diff --git a/pkg/api/console/v1/gqlgen.yaml b/pkg/api/console/v1/gqlgen.yaml index f3772920b..904edd9e0 100644 --- a/pkg/api/console/v1/gqlgen.yaml +++ b/pkg/api/console/v1/gqlgen.yaml @@ -22,3 +22,6 @@ models: Datetime: model: - "github.com/99designs/gqlgen/graphql.Time" + CursorKey: + model: + - "github.com/getprobo/probo/pkg/api/console/v1/types.CursorKeyScalar" diff --git a/pkg/api/console/v1/resolver.go b/pkg/api/console/v1/resolver.go index 6f2023bba..5c7583d86 100644 --- a/pkg/api/console/v1/resolver.go +++ b/pkg/api/console/v1/resolver.go @@ -2,6 +2,10 @@ package console_v1 +import "github.com/getprobo/probo/pkg/probo" + type ( - Resolver struct{} + Resolver struct { + svc *probo.Service + } ) diff --git a/pkg/api/console/v1/schema/schema.go b/pkg/api/console/v1/schema/schema.go index 07c614e07..cbbbb473c 100644 --- a/pkg/api/console/v1/schema/schema.go +++ b/pkg/api/console/v1/schema/schema.go @@ -15,6 +15,7 @@ import ( "github.com/99designs/gqlgen/graphql" "github.com/99designs/gqlgen/graphql/introspection" "github.com/getprobo/probo/pkg/api/console/v1/types" + "github.com/getprobo/probo/pkg/probo/coredata/page" gqlparser "github.com/vektah/gqlparser/v2" "github.com/vektah/gqlparser/v2/ast" ) @@ -54,7 +55,7 @@ type ComplexityRoot struct { Description func(childComplexity int) int ID func(childComplexity int) int Name func(childComplexity int) int - Tasks func(childComplexity int, first *int, after *string, last *int, before *string) int + Tasks func(childComplexity int, first *int, after *page.CursorKey, last *int, before *page.CursorKey) int UpdatedAt func(childComplexity int) int } @@ -69,7 +70,7 @@ type ComplexityRoot struct { } Framework struct { - Controls func(childComplexity int, first *int, after *string, last *int, before *string) int + Controls func(childComplexity int, first *int, after *page.CursorKey, last *int, before *page.CursorKey) int CreatedAt func(childComplexity int) int Description func(childComplexity int) int ID func(childComplexity int) int @@ -89,7 +90,7 @@ type ComplexityRoot struct { Organization struct { CreatedAt func(childComplexity int) int - Frameworks func(childComplexity int, first *int, after *string, last *int, before *string) int + Frameworks func(childComplexity int, first *int, after *page.CursorKey, last *int, before *page.CursorKey) int ID func(childComplexity int) int Name func(childComplexity int) int UpdatedAt func(childComplexity int) int @@ -125,13 +126,13 @@ type ComplexityRoot struct { } type ControlResolver interface { - Tasks(ctx context.Context, obj *types.Control, first *int, after *string, last *int, before *string) (*types.TaskConnection, error) + Tasks(ctx context.Context, obj *types.Control, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.TaskConnection, error) } type FrameworkResolver interface { - Controls(ctx context.Context, obj *types.Framework, first *int, after *string, last *int, before *string) (*types.ControlConnection, error) + Controls(ctx context.Context, obj *types.Framework, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.ControlConnection, error) } type OrganizationResolver interface { - Frameworks(ctx context.Context, obj *types.Organization, first *int, after *string, last *int, before *string) (*types.FrameworkConnection, error) + Frameworks(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.FrameworkConnection, error) } type QueryResolver interface { Node(ctx context.Context, id string) (types.Node, error) @@ -194,7 +195,7 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return 0, false } - return e.complexity.Control.Tasks(childComplexity, args["first"].(*int), args["after"].(*string), args["last"].(*int), args["before"].(*string)), true + return e.complexity.Control.Tasks(childComplexity, args["first"].(*int), args["after"].(*page.CursorKey), args["last"].(*int), args["before"].(*page.CursorKey)), true case "Control.updatedAt": if e.complexity.Control.UpdatedAt == nil { @@ -241,7 +242,7 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return 0, false } - return e.complexity.Framework.Controls(childComplexity, args["first"].(*int), args["after"].(*string), args["last"].(*int), args["before"].(*string)), true + return e.complexity.Framework.Controls(childComplexity, args["first"].(*int), args["after"].(*page.CursorKey), args["last"].(*int), args["before"].(*page.CursorKey)), true case "Framework.createdAt": if e.complexity.Framework.CreatedAt == nil { @@ -323,7 +324,7 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return 0, false } - return e.complexity.Organization.Frameworks(childComplexity, args["first"].(*int), args["after"].(*string), args["last"].(*int), args["before"].(*string)), true + return e.complexity.Organization.Frameworks(childComplexity, args["first"].(*int), args["after"].(*page.CursorKey), args["last"].(*int), args["before"].(*page.CursorKey)), true case "Organization.id": if e.complexity.Organization.ID == nil { @@ -688,13 +689,13 @@ func (ec *executionContext) field_Control_tasks_argsFirst( func (ec *executionContext) field_Control_tasks_argsAfter( ctx context.Context, rawArgs map[string]any, -) (*string, error) { +) (*page.CursorKey, error) { ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("after")) if tmp, ok := rawArgs["after"]; ok { - return ec.unmarshalOCursorKey2ᚖstring(ctx, tmp) + return ec.unmarshalOCursorKey2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋproboᚋcoredataᚋpageᚐCursorKey(ctx, tmp) } - var zeroVal *string + var zeroVal *page.CursorKey return zeroVal, nil } @@ -714,13 +715,13 @@ func (ec *executionContext) field_Control_tasks_argsLast( func (ec *executionContext) field_Control_tasks_argsBefore( ctx context.Context, rawArgs map[string]any, -) (*string, error) { +) (*page.CursorKey, error) { ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("before")) if tmp, ok := rawArgs["before"]; ok { - return ec.unmarshalOCursorKey2ᚖstring(ctx, tmp) + return ec.unmarshalOCursorKey2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋproboᚋcoredataᚋpageᚐCursorKey(ctx, tmp) } - var zeroVal *string + var zeroVal *page.CursorKey return zeroVal, nil } @@ -765,13 +766,13 @@ func (ec *executionContext) field_Framework_controls_argsFirst( func (ec *executionContext) field_Framework_controls_argsAfter( ctx context.Context, rawArgs map[string]any, -) (*string, error) { +) (*page.CursorKey, error) { ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("after")) if tmp, ok := rawArgs["after"]; ok { - return ec.unmarshalOCursorKey2ᚖstring(ctx, tmp) + return ec.unmarshalOCursorKey2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋproboᚋcoredataᚋpageᚐCursorKey(ctx, tmp) } - var zeroVal *string + var zeroVal *page.CursorKey return zeroVal, nil } @@ -791,13 +792,13 @@ func (ec *executionContext) field_Framework_controls_argsLast( func (ec *executionContext) field_Framework_controls_argsBefore( ctx context.Context, rawArgs map[string]any, -) (*string, error) { +) (*page.CursorKey, error) { ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("before")) if tmp, ok := rawArgs["before"]; ok { - return ec.unmarshalOCursorKey2ᚖstring(ctx, tmp) + return ec.unmarshalOCursorKey2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋproboᚋcoredataᚋpageᚐCursorKey(ctx, tmp) } - var zeroVal *string + var zeroVal *page.CursorKey return zeroVal, nil } @@ -842,13 +843,13 @@ func (ec *executionContext) field_Organization_frameworks_argsFirst( func (ec *executionContext) field_Organization_frameworks_argsAfter( ctx context.Context, rawArgs map[string]any, -) (*string, error) { +) (*page.CursorKey, error) { ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("after")) if tmp, ok := rawArgs["after"]; ok { - return ec.unmarshalOCursorKey2ᚖstring(ctx, tmp) + return ec.unmarshalOCursorKey2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋproboᚋcoredataᚋpageᚐCursorKey(ctx, tmp) } - var zeroVal *string + var zeroVal *page.CursorKey return zeroVal, nil } @@ -868,13 +869,13 @@ func (ec *executionContext) field_Organization_frameworks_argsLast( func (ec *executionContext) field_Organization_frameworks_argsBefore( ctx context.Context, rawArgs map[string]any, -) (*string, error) { +) (*page.CursorKey, error) { ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("before")) if tmp, ok := rawArgs["before"]; ok { - return ec.unmarshalOCursorKey2ᚖstring(ctx, tmp) + return ec.unmarshalOCursorKey2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋproboᚋcoredataᚋpageᚐCursorKey(ctx, tmp) } - var zeroVal *string + var zeroVal *page.CursorKey return zeroVal, nil } @@ -1100,7 +1101,7 @@ func (ec *executionContext) _Control_tasks(ctx context.Context, field graphql.Co ctx = graphql.WithFieldContext(ctx, fc) resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.Control().Tasks(rctx, obj, fc.Args["first"].(*int), fc.Args["after"].(*string), fc.Args["last"].(*int), fc.Args["before"].(*string)) + return ec.resolvers.Control().Tasks(rctx, obj, fc.Args["first"].(*int), fc.Args["after"].(*page.CursorKey), fc.Args["last"].(*int), fc.Args["before"].(*page.CursorKey)) }) if err != nil { ec.Error(ctx, err) @@ -1329,9 +1330,9 @@ func (ec *executionContext) _ControlEdge_cursor(ctx context.Context, field graph } return graphql.Null } - res := resTmp.(string) + res := resTmp.(page.CursorKey) fc.Result = res - return ec.marshalNCursorKey2string(ctx, field.Selections, res) + return ec.marshalNCursorKey2githubᚗcomᚋgetproboᚋproboᚋpkgᚋproboᚋcoredataᚋpageᚐCursorKey(ctx, field.Selections, res) } func (ec *executionContext) fieldContext_ControlEdge_cursor(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { @@ -1521,7 +1522,7 @@ func (ec *executionContext) _Framework_controls(ctx context.Context, field graph ctx = graphql.WithFieldContext(ctx, fc) resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.Framework().Controls(rctx, obj, fc.Args["first"].(*int), fc.Args["after"].(*string), fc.Args["last"].(*int), fc.Args["before"].(*string)) + return ec.resolvers.Framework().Controls(rctx, obj, fc.Args["first"].(*int), fc.Args["after"].(*page.CursorKey), fc.Args["last"].(*int), fc.Args["before"].(*page.CursorKey)) }) if err != nil { ec.Error(ctx, err) @@ -1750,9 +1751,9 @@ func (ec *executionContext) _FrameworkEdge_cursor(ctx context.Context, field gra } return graphql.Null } - res := resTmp.(string) + res := resTmp.(page.CursorKey) fc.Result = res - return ec.marshalNCursorKey2string(ctx, field.Selections, res) + return ec.marshalNCursorKey2githubᚗcomᚋgetproboᚋproboᚋpkgᚋproboᚋcoredataᚋpageᚐCursorKey(ctx, field.Selections, res) } func (ec *executionContext) fieldContext_FrameworkEdge_cursor(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { @@ -1904,7 +1905,7 @@ func (ec *executionContext) _Organization_frameworks(ctx context.Context, field ctx = graphql.WithFieldContext(ctx, fc) resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.Organization().Frameworks(rctx, obj, fc.Args["first"].(*int), fc.Args["after"].(*string), fc.Args["last"].(*int), fc.Args["before"].(*string)) + return ec.resolvers.Organization().Frameworks(rctx, obj, fc.Args["first"].(*int), fc.Args["after"].(*page.CursorKey), fc.Args["last"].(*int), fc.Args["before"].(*page.CursorKey)) }) if err != nil { ec.Error(ctx, err) @@ -2114,9 +2115,9 @@ func (ec *executionContext) _PageInfo_startCursor(ctx context.Context, field gra if resTmp == nil { return graphql.Null } - res := resTmp.(*string) + res := resTmp.(*page.CursorKey) fc.Result = res - return ec.marshalOCursorKey2ᚖstring(ctx, field.Selections, res) + return ec.marshalOCursorKey2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋproboᚋcoredataᚋpageᚐCursorKey(ctx, field.Selections, res) } func (ec *executionContext) fieldContext_PageInfo_startCursor(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { @@ -2149,9 +2150,9 @@ func (ec *executionContext) _PageInfo_endCursor(ctx context.Context, field graph if resTmp == nil { return graphql.Null } - res := resTmp.(*string) + res := resTmp.(*page.CursorKey) fc.Result = res - return ec.marshalOCursorKey2ᚖstring(ctx, field.Selections, res) + return ec.marshalOCursorKey2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋproboᚋcoredataᚋpageᚐCursorKey(ctx, field.Selections, res) } func (ec *executionContext) fieldContext_PageInfo_endCursor(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { @@ -2585,9 +2586,9 @@ func (ec *executionContext) _TaskEdge_cursor(ctx context.Context, field graphql. } return graphql.Null } - res := resTmp.(string) + res := resTmp.(page.CursorKey) fc.Result = res - return ec.marshalNCursorKey2string(ctx, field.Selections, res) + return ec.marshalNCursorKey2githubᚗcomᚋgetproboᚋproboᚋpkgᚋproboᚋcoredataᚋpageᚐCursorKey(ctx, field.Selections, res) } func (ec *executionContext) fieldContext_TaskEdge_cursor(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { @@ -5358,13 +5359,13 @@ func (ec *executionContext) marshalNControlEdge2ᚖgithubᚗcomᚋgetproboᚋpro return ec._ControlEdge(ctx, sel, v) } -func (ec *executionContext) unmarshalNCursorKey2string(ctx context.Context, v any) (string, error) { - res, err := graphql.UnmarshalString(v) +func (ec *executionContext) unmarshalNCursorKey2githubᚗcomᚋgetproboᚋproboᚋpkgᚋproboᚋcoredataᚋpageᚐCursorKey(ctx context.Context, v any) (page.CursorKey, error) { + res, err := types.UnmarshalCursorKeyScalar(v) return res, graphql.ErrorOnPath(ctx, err) } -func (ec *executionContext) marshalNCursorKey2string(ctx context.Context, sel ast.SelectionSet, v string) graphql.Marshaler { - res := graphql.MarshalString(v) +func (ec *executionContext) marshalNCursorKey2githubᚗcomᚋgetproboᚋproboᚋpkgᚋproboᚋcoredataᚋpageᚐCursorKey(ctx context.Context, sel ast.SelectionSet, v page.CursorKey) graphql.Marshaler { + res := types.MarshalCursorKeyScalar(v) if res == graphql.Null { if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { ec.Errorf(ctx, "the requested element is null which the schema does not allow") @@ -5837,19 +5838,19 @@ func (ec *executionContext) marshalOBoolean2ᚖbool(ctx context.Context, sel ast return res } -func (ec *executionContext) unmarshalOCursorKey2ᚖstring(ctx context.Context, v any) (*string, error) { +func (ec *executionContext) unmarshalOCursorKey2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋproboᚋcoredataᚋpageᚐCursorKey(ctx context.Context, v any) (*page.CursorKey, error) { if v == nil { return nil, nil } - res, err := graphql.UnmarshalString(v) + res, err := types.UnmarshalCursorKeyScalar(v) return &res, graphql.ErrorOnPath(ctx, err) } -func (ec *executionContext) marshalOCursorKey2ᚖstring(ctx context.Context, sel ast.SelectionSet, v *string) graphql.Marshaler { +func (ec *executionContext) marshalOCursorKey2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋproboᚋcoredataᚋpageᚐCursorKey(ctx context.Context, sel ast.SelectionSet, v *page.CursorKey) graphql.Marshaler { if v == nil { return graphql.Null } - res := graphql.MarshalString(*v) + res := types.MarshalCursorKeyScalar(*v) return res } diff --git a/pkg/api/console/v1/types/cursorkey.go b/pkg/api/console/v1/types/cursorkey.go new file mode 100644 index 000000000..789750038 --- /dev/null +++ b/pkg/api/console/v1/types/cursorkey.go @@ -0,0 +1,55 @@ +package types + +import ( + "errors" + "io" + "strconv" + + "github.com/99designs/gqlgen/graphql" + "github.com/getprobo/probo/pkg/probo/coredata/page" +) + +func NewCursor( + first *int, + after *page.CursorKey, + last *int, + before *page.CursorKey, +) *page.Cursor { + var ( + size int + from *page.CursorKey + direction = page.Head + ) + + if first != nil { + size = *first + direction = page.Head + from = after + } else if last != nil { + size = *last + direction = page.Tail + from = before + } + + return page.NewCursor(size, from, direction) +} + +func MarshalCursorKeyScalar(ck page.CursorKey) graphql.Marshaler { + return graphql.WriterFunc(func(w io.Writer) { + w.Write([]byte(strconv.Quote(ck.String()))) + }) +} + +func UnmarshalCursorKeyScalar(v interface{}) (page.CursorKey, error) { + s, ok := v.(string) + if !ok { + return page.CursorKeyNil, errors.New("must be a string") + } + + ck, err := page.ParseCursorKey(s) + if err != nil { + return page.CursorKeyNil, err + } + + return ck, nil +} diff --git a/pkg/api/console/v1/types/framework.go b/pkg/api/console/v1/types/framework.go new file mode 100644 index 000000000..c1dc8343a --- /dev/null +++ b/pkg/api/console/v1/types/framework.go @@ -0,0 +1,30 @@ +package types + +import ( + "github.com/getprobo/probo/pkg/probo/coredata" + "github.com/getprobo/probo/pkg/probo/coredata/page" +) + +func NewExecutionConnection(p *page.Page[*coredata.Framework]) *FrameworkConnection { + var edges = make([]*FrameworkEdge, len(p.Data)) + + for i := range edges { + edges[i] = NewFrameworkEdge(p.Data[i]) + } + + return &FrameworkConnection{ + Edges: edges, + PageInfo: NewPageInfo(p), + } +} + +func NewFrameworkEdge(f *coredata.Framework) *FrameworkEdge { + return &FrameworkEdge{ + Cursor: f.CursorKey(), + Node: NewExecution(f), + } +} + +func NewExecution(e *coredata.Framework) *Framework { + return &Framework{} +} diff --git a/pkg/api/console/v1/types/pageinfo.go b/pkg/api/console/v1/types/pageinfo.go new file mode 100644 index 000000000..e51a7f009 --- /dev/null +++ b/pkg/api/console/v1/types/pageinfo.go @@ -0,0 +1,25 @@ +package types + +import ( + "gearno.de/ref" + "github.com/getprobo/probo/pkg/probo/coredata/page" +) + +func NewPageInfo[T page.Paginable](p *page.Page[T]) *PageInfo { + var ( + startCursor *page.CursorKey + endCursor *page.CursorKey + ) + + if len(p.Data) > 0 { + startCursor = ref.Ref(p.First().CursorKey()) + endCursor = ref.Ref(p.Last().CursorKey()) + } + + return &PageInfo{ + HasNextPage: p.Info.HasNext, + HasPreviousPage: p.Info.HasPrev, + StartCursor: startCursor, + EndCursor: endCursor, + } +} diff --git a/pkg/api/console/v1/types/types.go b/pkg/api/console/v1/types/types.go index f9bf32001..05c223787 100644 --- a/pkg/api/console/v1/types/types.go +++ b/pkg/api/console/v1/types/types.go @@ -4,6 +4,8 @@ package types import ( "time" + + "github.com/getprobo/probo/pkg/probo/coredata/page" ) type Node interface { @@ -29,8 +31,8 @@ type ControlConnection struct { } type ControlEdge struct { - Cursor string `json:"cursor"` - Node *Control `json:"node"` + Cursor page.CursorKey `json:"cursor"` + Node *Control `json:"node"` } type Framework struct { @@ -51,8 +53,8 @@ type FrameworkConnection struct { } type FrameworkEdge struct { - Cursor string `json:"cursor"` - Node *Framework `json:"node"` + Cursor page.CursorKey `json:"cursor"` + Node *Framework `json:"node"` } type Organization struct { @@ -67,10 +69,10 @@ func (Organization) IsNode() {} func (this Organization) GetID() string { return this.ID } type PageInfo struct { - HasNextPage bool `json:"hasNextPage"` - HasPreviousPage bool `json:"hasPreviousPage"` - StartCursor *string `json:"startCursor,omitempty"` - EndCursor *string `json:"endCursor,omitempty"` + HasNextPage bool `json:"hasNextPage"` + HasPreviousPage bool `json:"hasPreviousPage"` + StartCursor *page.CursorKey `json:"startCursor,omitempty"` + EndCursor *page.CursorKey `json:"endCursor,omitempty"` } type Query struct { @@ -92,6 +94,6 @@ type TaskConnection struct { } type TaskEdge struct { - Cursor string `json:"cursor"` - Node *Task `json:"node"` + Cursor page.CursorKey `json:"cursor"` + Node *Task `json:"node"` } diff --git a/pkg/api/console/v1/v1_resolver.go b/pkg/api/console/v1/v1_resolver.go index 3839a90b1..220cd0397 100644 --- a/pkg/api/console/v1/v1_resolver.go +++ b/pkg/api/console/v1/v1_resolver.go @@ -10,21 +10,29 @@ import ( "github.com/getprobo/probo/pkg/api/console/v1/schema" "github.com/getprobo/probo/pkg/api/console/v1/types" + "github.com/getprobo/probo/pkg/probo/coredata/page" ) // Tasks is the resolver for the tasks field. -func (r *controlResolver) Tasks(ctx context.Context, obj *types.Control, first *int, after *string, last *int, before *string) (*types.TaskConnection, error) { +func (r *controlResolver) Tasks(ctx context.Context, obj *types.Control, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.TaskConnection, error) { panic(fmt.Errorf("not implemented: Tasks - tasks")) } // Controls is the resolver for the controls field. -func (r *frameworkResolver) Controls(ctx context.Context, obj *types.Framework, first *int, after *string, last *int, before *string) (*types.ControlConnection, error) { +func (r *frameworkResolver) Controls(ctx context.Context, obj *types.Framework, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.ControlConnection, error) { panic(fmt.Errorf("not implemented: Controls - controls")) } // Frameworks is the resolver for the frameworks field. -func (r *organizationResolver) Frameworks(ctx context.Context, obj *types.Organization, first *int, after *string, last *int, before *string) (*types.FrameworkConnection, error) { - panic(fmt.Errorf("not implemented: Frameworks - frameworks")) +func (r *organizationResolver) Frameworks(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.FrameworkConnection, error) { + cursor := types.NewCursor(first, after, last, before) + + page, err := r.svc.ListOrganizationFramework(ctx, "", cursor) + if err != nil { + return nil, fmt.Errorf("cannot list organization frameworks: %w", err) + } + + return types.NewExecutionConnection(page), nil } // Node is the resolver for the node field. diff --git a/pkg/probo/coredata/framework.go b/pkg/probo/coredata/framework.go index d7858cc92..880945a84 100644 --- a/pkg/probo/coredata/framework.go +++ b/pkg/probo/coredata/framework.go @@ -2,31 +2,97 @@ package coredata import ( "context" + "fmt" + "maps" "time" + "github.com/getprobo/probo/pkg/probo/coredata/page" "github.com/jackc/pgx/v5" + "go.gearno.de/crypto/uuid" "go.gearno.de/kit/pg" ) type ( Framework struct { - ID string + ID uuid.UUID OrganizationID string - ContentID string + Name string + Description string + ContentRef string CreatedAt time.Time UpdatedAt time.Time } + + Frameworks []*Framework ) +func (f *Framework) CursorKey() page.CursorKey { + return page.NewCursorKey(f.ID, f.CreatedAt) +} + func (f *Framework) scan(r pgx.Row) error { return r.Scan( &f.ID, &f.OrganizationID, - &f.ContentID, + &f.Name, + &f.Description, + &f.ContentRef, &f.CreatedAt, &f.UpdatedAt, ) } +func (f *Frameworks) LoadByOrganizationID( + ctx context.Context, + conn pg.Conn, + organizationID string, + cursor *page.Cursor, +) error { + q := ` +SELECT + framework_id, + organization_id, + name, + description, + content_ref, + created_at, + updated_at +FROM + frameworks +WHERE + organization_id = @organization_id + AND % +ORDER BY name ASC; +` + + q = fmt.Sprintf(q, cursor.SQLFragment()) + + args := pgx.NamedArgs{"organization_id": organizationID} + maps.Copy(args, cursor.SQLArguments()) + + r, err := conn.Query(ctx, q, args) + if err != nil { + return err + } + + frameworks := Frameworks{} + for r.Next() { + framework := &Framework{} + if err := framework.scan(r); err != nil { + return err + } + + frameworks = append(frameworks, framework) + } + defer r.Close() + + if err = r.Err(); err != nil { + return err + } + + *f = frameworks + + return nil +} func (f *Framework) LoadByID( ctx context.Context, @@ -37,7 +103,9 @@ func (f *Framework) LoadByID( SELECT framework_id, organization_id, - content_id, + name, + description, + content_ref, created_at, updated_at FROM diff --git a/pkg/probo/coredata/page/cursor.go b/pkg/probo/coredata/page/cursor.go new file mode 100644 index 000000000..90d841d28 --- /dev/null +++ b/pkg/probo/coredata/page/cursor.go @@ -0,0 +1,98 @@ +package page + +import ( + "fmt" + + "github.com/jackc/pgx/v5" +) + +type ( + Cursor struct { + Size int + Key *CursorKey + Position Position + } + + Position int8 +) + +const ( + DefaultCursorSize = 25 + + Tail Position = iota + Head +) + +func (p Position) ToDirection() string { + switch p { + case Tail: + return "ASC" + case Head: + return "DESC" + default: + panic(fmt.Errorf("unknown direction: %d", p)) + } +} + +func NewCursor(size int, from *CursorKey, pos Position) *Cursor { + if size == 0 { + size = DefaultCursorSize + } + + return &Cursor{ + Size: size, + Key: from, + Position: pos, + } +} + +func (c *Cursor) SQLFragment() string { + return ` + CASE + WHEN @cursor_order = 'DESC' AND @cursor_from_id::TEXT IS NOT NULL THEN ( + (created_at <= @cursor_from_ts) AND NOT (created_at = @cursor_from_ts AND id > @cursor_from_id) + ) + WHEN @cursor_order = 'ASC' AND @cursor_from_id::TEXT IS NOT NULL THEN ( + (created_at >= @cursor_from_ts) AND NOT (created_at = @cursor_from_ts AND id < @cursor_from_id) + ) + ELSE TRUE + END +ORDER BY + CASE + WHEN @cursor_order = 'ASC' THEN created_at + END ASC, + CASE + WHEN @cursor_order = 'ASC' THEN id + END ASC, + CASE + WHEN @cursor_order = 'DESC' THEN created_at + END DESC, + CASE + WHEN @cursor_order = 'DESC' THEN id + END DESC +LIMIT @cursor_limit +` +} + +func (c *Cursor) SQLArguments() pgx.NamedArgs { + var size = c.Size + if c.Key == nil { + size += 1 + } else { + size += 2 + } + + arguments := pgx.NamedArgs{ + "cursor_order": c.Position.ToDirection(), + "cursor_limit": size, + "cursor_from_id": nil, + "cursor_from_ts": nil, + } + + if c.Key != nil { + arguments["cursor_from_id"] = c.Key.ID() + arguments["cursor_from_ts"] = c.Key.Timestamp() + } + + return arguments +} diff --git a/pkg/probo/coredata/page/cursorkey.go b/pkg/probo/coredata/page/cursorkey.go new file mode 100644 index 000000000..705a5c43b --- /dev/null +++ b/pkg/probo/coredata/page/cursorkey.go @@ -0,0 +1,105 @@ +package page + +import ( + "encoding/base64" + "encoding/binary" + "errors" + "time" + + "go.gearno.de/crypto/uuid" +) + +type ( + CursorKey [byteLength]byte +) + +var ( + CursorKeyNil CursorKey + + ErrInvalidFormat = errors.New("invalid format") +) + +const ( + byteLength = 28 +) + +func ParseCursorKey(s string) (CursorKey, error) { + b, err := base64.RawURLEncoding.DecodeString(s) + if err != nil { + return CursorKeyNil, ErrInvalidFormat + } + + ck, err := CursorKeyFromBytes(b) + if err != nil { + return CursorKeyNil, ErrInvalidFormat + } + + return ck, nil +} + +func CursorKeyFromBytes(b []byte) (CursorKey, error) { + var ck CursorKey + + if len(b) != byteLength { + return CursorKeyNil, ErrInvalidFormat + } + + copy(ck[:], b) + + return ck, nil +} + +func NewCursorKey(id uuid.UUID, t time.Time) CursorKey { + var cursorKey CursorKey + copy(cursorKey[:20], id[:]) + _ = binary.PutVarint(cursorKey[20:], t.UnixMicro()) + + return cursorKey +} + +func (ck CursorKey) Bytes() []byte { + return ck[:] +} + +func (ck CursorKey) String() string { + return base64.RawURLEncoding.EncodeToString(ck.Bytes()) +} + +func (ck CursorKey) Timestamp() time.Time { + return ck.ID().Timestamp() +} + +func (ck CursorKey) ID() uuid.UUID { + id, _ := uuid.FromBytes(ck[:]) + return id +} + +func (ck CursorKey) MarshalText() ([]byte, error) { + return []byte(ck.String()), nil +} + +func (ck *CursorKey) UnmarshalText(data []byte) error { + ck2, err := ParseCursorKey(string(data)) + if err != nil { + return err + } + + *ck = ck2 + + return nil +} + +func (ck CursorKey) MarshalBinary() ([]byte, error) { + return ck.Bytes(), nil +} + +func (ck *CursorKey) UnmarshalBinary(b []byte) error { + ck2, err := CursorKeyFromBytes(b) + if err != nil { + return err + } + + *ck = ck2 + + return nil +} diff --git a/pkg/probo/coredata/page/page.go b/pkg/probo/coredata/page/page.go new file mode 100644 index 000000000..a88f04df3 --- /dev/null +++ b/pkg/probo/coredata/page/page.go @@ -0,0 +1,101 @@ +package page + +type ( + Paginable interface { + CursorKey() CursorKey + } + + PageInfo struct { + HasNext bool + HasPrev bool + } + + Page[T Paginable] struct { + Info *PageInfo + Data []T + } +) + +func (p *Page[T]) First() T { + if len(p.Data) == 0 { + var zero T + return zero + } + + return p.Data[0] +} + +func (p *Page[T]) Last() T { + if len(p.Data) == 0 { + var zero T + return zero + } + + return p.Data[len(p.Data)-1] +} + +func NewPage[T Paginable](data []T, c *Cursor) *Page[T] { + pi := &PageInfo{} + + if len(data) == 0 { + return &Page[T]{ + Info: pi, + Data: data, + } + } + + edges := data + firstFromData := data[0] + + switch c.Position { + case Head: + if c.Key != nil { + if len(edges) == c.Size+2 { + edges = edges[1 : len(edges)-1] + } else { + edges = edges[1:] + } + } else if c.Key == nil && len(edges) == c.Size+1 { + edges = edges[0 : len(edges)-1] + } + + if c.Key != nil && *c.Key == firstFromData.CursorKey() { + pi.HasPrev = true + } + + if c.Key != nil && c.Size+2 == len(data) { + pi.HasNext = true + } else if c.Key == nil && c.Size+1 == len(data) { + pi.HasNext = true + } + case Tail: + for i, j := 0, len(edges)-1; i < j; i, j = i+1, j-1 { + edges[i], edges[j] = edges[j], edges[i] + } + + if c.Key != nil { + if len(edges) == c.Size+2 { + edges = edges[1 : len(edges)-1] + } else { + edges = edges[0 : len(edges)-1] + } + } else if c.Key == nil && len(edges) == c.Size+1 { + edges = edges[1:] + } + + if c.Key != nil && *c.Key == firstFromData.CursorKey() { + pi.HasNext = true + } + + if c.Key != nil && c.Size+2 == len(data) { + pi.HasPrev = true + } else if c.Key == nil && c.Size+1 == len(data) { + pi.HasPrev = true + } + } + + return &Page[T]{ + Info: pi, + Data: edges, + } +} diff --git a/pkg/probo/probo.go b/pkg/probo/probo.go index 9ded00ce7..1eb4ea1f9 100644 --- a/pkg/probo/probo.go +++ b/pkg/probo/probo.go @@ -3,6 +3,8 @@ package probo import ( "context" + "github.com/getprobo/probo/pkg/probo/coredata" + "github.com/getprobo/probo/pkg/probo/coredata/page" "go.gearno.de/kit/pg" ) @@ -17,3 +19,44 @@ func NewService(ctx context.Context, pgClient *pg.Client) *Service { pg: pgClient, } } + +func (s *Service) GetOrganization( + ctx context.Context, + organizationID string, +) (*coredata.Organization, error) { + organization := &coredata.Organization{} + + err := s.pg.WithConn( + ctx, + func(conn pg.Conn) error { + return organization.LoadByID(ctx, conn, organizationID) + }, + ) + + if err != nil { + return nil, err + } + + return organization, nil +} + +func (s *Service) ListOrganizationFramework( + ctx context.Context, + organizationID string, + cursor *page.Cursor, +) (*page.Page[*coredata.Framework], error) { + var frameworks coredata.Frameworks + + err := s.pg.WithConn( + ctx, + func(conn pg.Conn) error { + return frameworks.LoadByOrganizationID(ctx, conn, organizationID, cursor) + }, + ) + + if err != nil { + return nil, err + } + + return page.NewPage(frameworks, cursor), nil +}