From d1f0e13f3e4f629f6d56b1d0a041a3f66262c8af Mon Sep 17 00:00:00 2001 From: Raghd Hamzeh Date: Wed, 17 Jun 2026 17:33:03 -0400 Subject: [PATCH 1/3] feat: report serialized model size in fga model get and validate --- .golangci.yaml | 2 + README.md | 31 +++++-- cmd/model/get.go | 2 +- cmd/model/validate.go | 6 +- cmd/model/validate_test.go | 44 ++++++++- internal/authorizationmodel/model.go | 65 +++++++++---- internal/authorizationmodel/model_test.go | 107 ++++++++++++++++++++++ 7 files changed, 229 insertions(+), 28 deletions(-) diff --git a/.golangci.yaml b/.golangci.yaml index 4b35ec42..81cd6738 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -43,6 +43,7 @@ linters: - github.com/spf13/viper - golang.org/x/time/rate - google.golang.org/protobuf/encoding/protojson + - google.golang.org/protobuf/proto - google.golang.org/protobuf/types/known/structpb - gopkg.in/yaml.v3 test: @@ -59,6 +60,7 @@ linters: - go.uber.org/mock/gomock - github.com/spf13/cobra - github.com/spf13/viper + - google.golang.org/protobuf/proto funlen: lines: 120 statements: 80 diff --git a/README.md b/README.md index 7f5acf1d..352bea6b 100644 --- a/README.md +++ b/README.md @@ -491,13 +491,16 @@ fga model **get** * `--store-id`: Specifies the store id * `--model-id`: Specifies the model id * `--format`: Authorization model output format. Can be "fga" or "json" (default fga). -* `--field`: Fields to display, choices are: `id`, `created_at` and `model`. Default is `model`. +* `--field`: Fields to display, choices are: `id`, `created_at`, `size` and `model`. Default is `model`. ###### Example -`fga model get --store-id=01H0H015178Y2V4CX10C2KGHF4 --model-id=01GXSA8YR785C4FYS3C0RTG7B1` +`fga model get --store-id=01H0H015178Y2V4CX10C2KGHF4 --model-id=01GXSA8YR785C4FYS3C0RTG7B1 --field size --field model --field id --field created_at` ###### Response ```python +# Model ID: 01GXSA8YR785C4FYS3C0RTG7B1 +# Created At: 2023-04-11 23:26:34.759 +0000 UTC +# Size: 20.05 KB model schema 1.1 @@ -508,6 +511,20 @@ type document define can_view: [user] ``` +In `json` format, the fields appear intermixed with the model. + +`fga model get --store-id=01H0H015178Y2V4CX10C2KGHF4 --model-id=01GXSA8YR785C4FYS3C0RTG7B1 --field size --field model --field id --field created_at --format=json` + +```json +{ + "id":"01GXSA8YR785C4FYS3C0RTG7B1", + "created_at":"2023-04-11T23:26:34.759Z", + "size_kb":20.05, + "schema_version":"1.1", + "type_definitions": [...] +} +``` + ##### Read the Latest Authorization Model If `model-id` is not specified when using the `get` command, the latest authorization model will be returned. @@ -545,22 +562,24 @@ fga model **validate** ###### Example `fga model validate --file model.json` +When the input parses successfully, the JSON response includes `size_kb`, the protobuf-serialized size of the model in KB. If the input cannot be parsed, the command exits with an error and does not emit a JSON response. + ###### JSON Response * Valid model with an ID ```json5 -{"id":"01GPGWB8R33HWXS3KK6YG4ETGH","created_at":"2023-01-11T16:59:22Z","is_valid":true} +{"id":"01GPGWB8R33HWXS3KK6YG4ETGH","created_at":"2023-01-11T16:59:22Z","is_valid":true,"size_kb":0.05} ``` * Valid model without an ID ```json5 -{"is_valid":true} +{"is_valid":true,"size_kb":0.05} ``` * Invalid model with an ID ```json5 -{"id":"01GPGTVEH5NYTQ19RYFQKE0Q4Z","created_at":"2023-01-11T16:33:15Z","is_valid":false,"error":"invalid schema version"} +{"id":"01GPGTVEH5NYTQ19RYFQKE0Q4Z","created_at":"2023-01-11T16:33:15Z","is_valid":false,"error":"invalid schema version","size_kb":0.05} ``` * Invalid model without an ID ```json5 -{"is_valid":false,"error":"the relation type 'employee' on 'member' in object type 'group' is not valid"} +{"is_valid":false,"error":"the relation type 'employee' on 'member' in object type 'group' is not valid","size_kb":0.05} ``` ##### Run Tests on an Authorization Model diff --git a/cmd/model/get.go b/cmd/model/get.go index a080e2ba..5b1676be 100644 --- a/cmd/model/get.go +++ b/cmd/model/get.go @@ -74,7 +74,7 @@ var getOutputFormat = authorizationmodel.ModelFormatFGA func init() { getCmd.Flags().String("model-id", "", "Authorization Model ID") getCmd.Flags().String("store-id", "", "Store ID") - getCmd.Flags().StringArray("field", []string{"model"}, "Fields to display, choices are: id, created_at and model") //nolint:lll + getCmd.Flags().StringArray("field", []string{"model"}, "Fields to display, choices are: id, created_at, size and model") //nolint:lll getCmd.Flags().Var(&getOutputFormat, "format", `Authorization model output format. Can be "fga" or "json"`) if err := getCmd.MarkFlagRequired("store-id"); err != nil { diff --git a/cmd/model/validate.go b/cmd/model/validate.go index a6a6da3a..5bef94a4 100644 --- a/cmd/model/validate.go +++ b/cmd/model/validate.go @@ -37,6 +37,7 @@ type validationResult struct { CreatedAt *time.Time `json:"created_at,omitempty"` IsValid bool `json:"is_valid"` Error *string `json:"error,omitempty"` + SizeKB *float64 `json:"size_kb,omitempty"` } func validate(inputModel authorizationmodel.AuthzModel) validationResult { @@ -54,7 +55,7 @@ func validate(inputModel authorizationmodel.AuthzModel) validationResult { return output } - err = protojson.Unmarshal([]byte(*modelJSONString), model) + err = (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal([]byte(*modelJSONString), model) if err != nil { output.IsValid = false errorString := "unable to parse json input" @@ -63,6 +64,9 @@ func validate(inputModel authorizationmodel.AuthzModel) validationResult { return output } + sizeKB := inputModel.GetSizeInKB() + output.SizeKB = &sizeKB + if model.GetId() != "" { output.ID = model.GetId() diff --git a/cmd/model/validate_test.go b/cmd/model/validate_test.go index 5c289e7c..f5dc2a60 100644 --- a/cmd/model/validate_test.go +++ b/cmd/model/validate_test.go @@ -87,11 +87,51 @@ func TestValidate(t *testing.T) { return } + expected := test.ExpectedOutput + size := model.GetSizeInKB() + expected.SizeKB = &size + output := validate(model) - if !reflect.DeepEqual(output, test.ExpectedOutput) { - t.Fatalf("Expect output %v actual %v", test.ExpectedOutput, output) + if !reflect.DeepEqual(output, expected) { + t.Fatalf("Expect output %v actual %v", expected, output) } }) } } + +func TestValidateReportsSize(t *testing.T) { + t.Parallel() + + model := authorizationmodel.AuthzModel{} + if err := model.ReadFromJSONString(`{"schema_version":"1.1"}`); err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + result := validate(model) + if result.SizeKB == nil { + t.Fatalf("expected SizeKB to be set") + } + + if *result.SizeKB != model.GetSizeInKB() { + t.Errorf("expected %v to equal %v", *result.SizeKB, model.GetSizeInKB()) + } +} + +func TestValidateWithValidIDReportsSize(t *testing.T) { + t.Parallel() + + model := authorizationmodel.AuthzModel{} + if err := model.ReadFromJSONString(`{"id":"01GVKXGDCV2SMG6TRE9NMBQ2VG","schema_version":"1.1"}`); err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + result := validate(model) + if !result.IsValid { + t.Fatalf("expected valid model, got error: %v", *result.Error) + } + + if result.SizeKB == nil { + t.Fatalf("expected SizeKB to be set") + } +} diff --git a/internal/authorizationmodel/model.go b/internal/authorizationmodel/model.go index 7f2ea3a6..8991e6ca 100644 --- a/internal/authorizationmodel/model.go +++ b/internal/authorizationmodel/model.go @@ -20,6 +20,7 @@ import ( "encoding/json" "errors" "fmt" + "math" "os" "path" "time" @@ -29,6 +30,7 @@ import ( openfga "github.com/openfga/go-sdk" language "github.com/openfga/language/pkg/go/transformer" "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" "github.com/openfga/cli/internal/slices" ) @@ -51,6 +53,7 @@ type AuthzModelList struct { type AuthzModel struct { ID *string `json:"id,omitempty"` CreatedAt *time.Time `json:"created_at,omitempty"` + SizeKB *float64 `json:"size_kb,omitempty"` SchemaVersion *string `json:"schema_version,omitempty"` TypeDefinitions *[]openfga.TypeDefinition `json:"type_definitions,omitempty"` Conditions map[string]*openfga.Condition `json:"conditions,omitempty"` @@ -112,13 +115,24 @@ func (model *AuthzModel) GetProtoModel() *pb.AuthorizationModel { return nil } - if err = protojson.Unmarshal([]byte(*jsonModel), &pbModel); err != nil { + if err = (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal([]byte(*jsonModel), &pbModel); err != nil { return nil } return &pbModel } +func (model *AuthzModel) GetSizeInKB() float64 { + pbModel := model.GetProtoModel() + if pbModel == nil { + return 0 + } + + sizeKB := float64(proto.Size(pbModel)) / 1024.0 //nolint:mnd + + return math.Round(sizeKB*100) / 100 //nolint:mnd +} + func (model *AuthzModel) GetCreatedAt() *time.Time { if model == nil { return nil @@ -318,6 +332,11 @@ func (model *AuthzModel) DisplayAsJSON(fields []string) AuthzModel { newModel.Conditions = model.Conditions } + if slices.Contains(fields, "size") { + size := model.GetSizeInKB() + newModel.SizeKB = &size + } + return newModel } @@ -328,23 +347,7 @@ func (model *AuthzModel) DisplayAsDSL(fields []string) (*string, error) { fields = append(fields, "model") } - dslModel := "" - - if slices.Contains(fields, "id") { - if model.ID != nil { - dslModel += fmt.Sprintf("# Model ID: %v\n", *model.ID) - } else { - dslModel += fmt.Sprintf("# Model ID: %v\n", "N/A") - } - } - - if slices.Contains(fields, "created_at") { - if model.CreatedAt != nil { - dslModel += fmt.Sprintf("# Created At: %v\n", *model.CreatedAt) - } else { - dslModel += fmt.Sprintf("# Created At: %v\n", "N/A") - } - } + dslModel := model.buildDSLMetadata(fields) if slices.Contains(fields, "model") { modelJSON, err := model.GetAsJSONString() @@ -368,6 +371,32 @@ func (model *AuthzModel) DisplayAsDSL(fields []string) (*string, error) { return &dslModel, nil } +func (model *AuthzModel) buildDSLMetadata(fields []string) string { + metadata := "" + + if slices.Contains(fields, "id") { + if model.ID != nil { + metadata += fmt.Sprintf("# Model ID: %v\n", *model.ID) + } else { + metadata += fmt.Sprintf("# Model ID: %v\n", "N/A") + } + } + + if slices.Contains(fields, "created_at") { + if model.CreatedAt != nil { + metadata += fmt.Sprintf("# Created At: %v\n", *model.CreatedAt) + } else { + metadata += fmt.Sprintf("# Created At: %v\n", "N/A") + } + } + + if slices.Contains(fields, "size") { + metadata += fmt.Sprintf("# Size: %.2f KB\n", model.GetSizeInKB()) + } + + return metadata +} + func (model *AuthzModel) setCreatedAt() { if *model.ID != "" { modelID, err := ulid.Parse(*model.ID) diff --git a/internal/authorizationmodel/model_test.go b/internal/authorizationmodel/model_test.go index 7de2d57c..6e348699 100644 --- a/internal/authorizationmodel/model_test.go +++ b/internal/authorizationmodel/model_test.go @@ -1,10 +1,13 @@ package authorizationmodel_test import ( + "math" + "strings" "testing" openfga "github.com/openfga/go-sdk" "github.com/openfga/openfga/pkg/typesystem" + "google.golang.org/protobuf/proto" "github.com/openfga/cli/internal/authorizationmodel" ) @@ -124,3 +127,107 @@ func TestDisplayAsJsonWithFields(t *testing.T) { t.Errorf("Expected %v to equal nil", jsonModel2.GetTypeDefinitions()) } } + +func TestGetSizeInKB(t *testing.T) { + t.Parallel() + + typeDefs := []openfga.TypeDefinition{{Type: typeName}} + model := authorizationmodel.AuthzModel{ + SchemaVersion: openfga.PtrString(typesystem.SchemaVersion1_1), + ID: openfga.PtrString(modelID), + TypeDefinitions: &typeDefs, + } + + size := model.GetSizeInKB() + if size <= 0 { + t.Errorf("Expected positive size, got %v", size) + } + + pbModel := model.GetProtoModel() + + bytes, err := proto.Marshal(pbModel) + if err != nil { + t.Fatalf("unexpected marshal error: %v", err) + } + + expected := math.Round(float64(len(bytes))/1024.0*100) / 100 + if size != expected { + t.Errorf("Expected %v to equal %v", size, expected) + } + + if size != math.Round(size*100)/100 { + t.Errorf("Expected %v to be rounded to 2 decimal places", size) + } +} + +func TestGetSizeInKBWithCreatedAt(t *testing.T) { + t.Parallel() + + model := authorizationmodel.AuthzModel{} + model.Set(openfga.AuthorizationModel{ + Id: modelID, + SchemaVersion: typesystem.SchemaVersion1_1, + TypeDefinitions: []openfga.TypeDefinition{{Type: typeName}}, + }) + + if model.GetCreatedAt() == nil { + t.Fatalf("expected CreatedAt to be populated by Set") + } + + size := model.GetSizeInKB() + if size <= 0 { + t.Errorf("Expected positive size for store-fetched model, got %v", size) + } +} + +func TestDisplayAsJSONWithSize(t *testing.T) { + t.Parallel() + + typeDefs := []openfga.TypeDefinition{{Type: typeName}} + model := authorizationmodel.AuthzModel{ + SchemaVersion: openfga.PtrString(typesystem.SchemaVersion1_1), + ID: openfga.PtrString(modelID), + TypeDefinitions: &typeDefs, + } + + withSize := model.DisplayAsJSON([]string{"model", "size"}) + if withSize.SizeKB == nil { + t.Fatalf("Expected SizeKB to be set") + } + + if *withSize.SizeKB != model.GetSizeInKB() { + t.Errorf("Expected %v to equal %v", *withSize.SizeKB, model.GetSizeInKB()) + } + + withoutSize := model.DisplayAsJSON([]string{"model"}) + if withoutSize.SizeKB != nil { + t.Errorf("Expected SizeKB to be nil, got %v", *withoutSize.SizeKB) + } +} + +func TestDisplayAsDSLWithSize(t *testing.T) { + t.Parallel() + + model := authorizationmodel.AuthzModel{} + if err := model.ReadFromDSLString("model\n schema 1.1\n\ntype user\n"); err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + withSize, err := model.DisplayAsDSL([]string{"model", "size"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(*withSize, "# Size: ") { + t.Errorf("Expected DSL output to contain a size comment, got %v", *withSize) + } + + withoutSize, err := model.DisplayAsDSL([]string{"model"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if strings.Contains(*withoutSize, "# Size: ") { + t.Errorf("Expected DSL output to omit size comment, got %v", *withoutSize) + } +} From fc049ccd4690a31d23a37db34a72317be68818f5 Mon Sep 17 00:00:00 2001 From: Raghd Hamzeh Date: Wed, 17 Jun 2026 21:04:54 -0400 Subject: [PATCH 2/3] chore: assert parse failures in TestValidate instead of silently skipping --- cmd/model/validate_test.go | 48 +++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/cmd/model/validate_test.go b/cmd/model/validate_test.go index f5dc2a60..66202fcb 100644 --- a/cmd/model/validate_test.go +++ b/cmd/model/validate_test.go @@ -22,26 +22,21 @@ func TestValidate(t *testing.T) { t.Parallel() type validationTest struct { - Name string - Input string - IsValid bool - ExpectedOutput validationResult + Name string + Input string + ExpectParseError bool + ExpectedOutput validationResult } tests := []validationTest{ { - Name: "missing schema version", - Input: "{", - IsValid: false, - ExpectedOutput: validationResult{ - IsValid: false, - Error: openfga.PtrString("unable to parse json input"), - }, + Name: "invalid json", + Input: "{", + ExpectParseError: true, }, { - Name: "missing schema version", - Input: `{"id":"abcde","schema_version":"1.1"}`, - IsValid: false, + Name: "invalid model id", + Input: `{"id":"abcde","schema_version":"1.1"}`, ExpectedOutput: validationResult{ ID: "abcde", IsValid: false, @@ -49,27 +44,24 @@ func TestValidate(t *testing.T) { }, }, { - Name: "missing schema version", - Input: "{}", - IsValid: false, + Name: "missing schema version", + Input: "{}", ExpectedOutput: validationResult{ IsValid: false, Error: openfga.PtrString("invalid schema version"), }, }, { - Name: "invalid schema version", - Input: `{"schema_version":"1.0"}`, - IsValid: false, + Name: "invalid schema version", + Input: `{"schema_version":"1.0"}`, ExpectedOutput: validationResult{ IsValid: false, Error: openfga.PtrString("invalid schema version"), }, }, { - Name: "only schema", - Input: `{"schema_version":"1.1"}`, - IsValid: true, + Name: "only schema", + Input: `{"schema_version":"1.1"}`, ExpectedOutput: validationResult{ IsValid: true, }, @@ -83,10 +75,18 @@ func TestValidate(t *testing.T) { model := authorizationmodel.AuthzModel{} err := model.ReadFromJSONString(test.Input) - if err != nil { + if test.ExpectParseError { + if err == nil { + t.Fatalf("Expected parse error for input %q, got none", test.Input) + } + return } + if err != nil { + t.Fatalf("Unexpected parse error for input %q: %v", test.Input, err) + } + expected := test.ExpectedOutput size := model.GetSizeInKB() expected.SizeKB = &size From c4e659089cd507c006beb7e43717722b2f0368ad Mon Sep 17 00:00:00 2001 From: Raghd Hamzeh Date: Wed, 17 Jun 2026 21:11:42 -0400 Subject: [PATCH 3/3] chore: avoid computing the model twice in validate --- cmd/model/validate.go | 2 +- internal/authorizationmodel/model.go | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/cmd/model/validate.go b/cmd/model/validate.go index 5bef94a4..cc757ba7 100644 --- a/cmd/model/validate.go +++ b/cmd/model/validate.go @@ -64,7 +64,7 @@ func validate(inputModel authorizationmodel.AuthzModel) validationResult { return output } - sizeKB := inputModel.GetSizeInKB() + sizeKB := authorizationmodel.ProtoModelSizeInKB(model) output.SizeKB = &sizeKB if model.GetId() != "" { diff --git a/internal/authorizationmodel/model.go b/internal/authorizationmodel/model.go index 8991e6ca..6ec4b88a 100644 --- a/internal/authorizationmodel/model.go +++ b/internal/authorizationmodel/model.go @@ -123,7 +123,12 @@ func (model *AuthzModel) GetProtoModel() *pb.AuthorizationModel { } func (model *AuthzModel) GetSizeInKB() float64 { - pbModel := model.GetProtoModel() + return ProtoModelSizeInKB(model.GetProtoModel()) +} + +// ProtoModelSizeInKB returns the protobuf-serialized size of the model in KB, +// rounded to two decimal places. Returns 0 for a nil model. +func ProtoModelSizeInKB(pbModel *pb.AuthorizationModel) float64 { if pbModel == nil { return 0 }