From 0c57bca195a61761a47deaedfab0dd71c46c9fbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Grzegorz=20Burzy=C5=84ski?= Date: Mon, 24 Apr 2023 18:16:32 +0200 Subject: [PATCH] feat: add FillID method for Service, Route and Consumer (#299) --- CHANGELOG.md | 8 +++ kong/ids.go | 126 +++++++++++++++++++++++++++++++++++++++++++++++ kong/ids_test.go | 125 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 259 insertions(+) create mode 100644 kong/ids.go create mode 100644 kong/ids_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 4279d7fa7..a9dcbf841 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,14 @@ - [0.2.0](#020) - [0.1.0](#010) +## [v0.41.0] + +> Release date: TBD + +- Added `FillID` method for `Service`, `Route` and `Consumer` entities. It allows + setting a deterministic ID to an entity. + [#299](https://github.com/Kong/go-kong/pull/299) + ## [v0.40.0] > Release date: 2023/04/07 diff --git a/kong/ids.go b/kong/ids.go new file mode 100644 index 000000000..34c9acbd5 --- /dev/null +++ b/kong/ids.go @@ -0,0 +1,126 @@ +package kong + +import ( + "fmt" + "reflect" + + "github.com/google/uuid" +) + +// FillID fills the ID of an entity. It is a no-op if the entity already has an ID. +// ID is generated in a deterministic way using UUIDv5. The UUIDv5 namespace is different for each entity type. +// The name used to generate the ID for Service is Service.Name. +func (s *Service) FillID() error { + if s == nil { + return fmt.Errorf("service is nil") + } + if s.ID != nil { + // ID already set, do nothing. + return nil + } + if s.Name == nil || *s.Name == "" { + return fmt.Errorf("service name is required") + } + + gen, err := idGeneratorFor(s) + if err != nil { + return fmt.Errorf("could not get id generator: %w", err) + } + + s.ID = gen.buildIDFor(*s.Name) + return nil +} + +// FillID fills the ID of an entity. It is a no-op if the entity already has an ID. +// ID is generated in a deterministic way using UUIDv5. The UUIDv5 namespace is different for each entity type. +// The name used to generate the ID for Route is Route.Name. +func (r *Route) FillID() error { + if r == nil { + return fmt.Errorf("route is nil") + } + if r.ID != nil { + // ID already set, do nothing. + return nil + } + if r.Name == nil || *r.Name == "" { + return fmt.Errorf("route name is required") + } + + gen, err := idGeneratorFor(r) + if err != nil { + return fmt.Errorf("could not get id generator: %w", err) + } + + r.ID = gen.buildIDFor(*r.Name) + return nil +} + +// FillID fills the ID of an entity. It is a no-op if the entity already has an ID. +// ID is generated in a deterministic way using UUIDv5. The UUIDv5 namespace is different for each entity type. +// The name used to generate the ID for Consumer is Consumer.Username. +func (c *Consumer) FillID() error { + if c == nil { + return fmt.Errorf("consumer is nil") + } + if c.ID != nil { + // ID already set, do nothing. + return nil + } + if c.Username == nil || *c.Username == "" { + return fmt.Errorf("consumer username is required") + } + + gen, err := idGeneratorFor(c) + if err != nil { + return fmt.Errorf("could not get id generator: %w", err) + } + + c.ID = gen.buildIDFor(*c.Username) + return nil +} + +var ( + // _kongEntitiesNamespace is the UUIDv5 namespace used to generate IDs for Kong entities. + _kongEntitiesNamespace = uuid.MustParse("fd02801f-0957-4a15-a55a-c8d9606f30b5") + + // _idGenerators is a map of entity type to ID generator. + // Plural names of entities are used as names for UUIDv5 namespaces to match Kong's behavior which uses schemas + // names for that purpose. + // See https://github.com/Kong/kong/blob/master/kong/db/schema/others/declarative_config.lua for reference. + _idGenerators = map[reflect.Type]idGenerator{ + reflect.TypeOf(Service{}): newIDGeneratorFor("services"), + reflect.TypeOf(Route{}): newIDGeneratorFor("routes"), + reflect.TypeOf(Consumer{}): newIDGeneratorFor("consumers"), + } +) + +type idGenerator struct { + namespace uuid.UUID +} + +func (g idGenerator) buildIDFor(entityKey string) *string { + id := uuid.NewSHA1(g.namespace, []byte(entityKey)).String() + return &id +} + +// newIDGeneratorFor returns a new ID generator for the given entity type. Should be used only to initialize _idGenerators. +func newIDGeneratorFor(entityPluralName string) idGenerator { + entityTypeNamespace := uuid.NewSHA1(_kongEntitiesNamespace, []byte(entityPluralName)) + return idGenerator{namespace: entityTypeNamespace} +} + +// IDFillable is a type constraint for entities that can be filled with an ID. +type IDFillable interface { + FillID() error +} + +// idGeneratorFor returns the ID generator for the given entity type. +func idGeneratorFor(entity IDFillable) (idGenerator, error) { + generator, ok := _idGenerators[reflect.TypeOf(entity).Elem()] + if !ok { + // This should never happen, as the map is initialized with all supported entity types. + // If it does happen, it is a bug in the code. + return idGenerator{}, fmt.Errorf("unsupported entity type: '%T'", entity) + } + return generator, nil +} diff --git a/kong/ids_test.go b/kong/ids_test.go new file mode 100644 index 000000000..908e975d0 --- /dev/null +++ b/kong/ids_test.go @@ -0,0 +1,125 @@ +package kong_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/kong/go-kong/kong" +) + +type fillEntityIDTestCase struct { + name string + entity kong.IDFillable + + assertEntity func(t *testing.T, entity kong.IDFillable) + expectErr bool +} + +func TestFillEntityID(t *testing.T) { + testCases := []fillEntityIDTestCase{ + // Service + { + name: "service nil pointer", + entity: (*kong.Service)(nil), + expectErr: true, + }, + { + name: "service with nil name", + entity: &kong.Service{}, + expectErr: true, + }, + { + name: "service with empty name", + entity: &kong.Service{Name: kong.String("")}, + expectErr: true, + }, + { + name: "service with name", + entity: &kong.Service{ + Name: kong.String("some.service.name"), + }, + assertEntity: func(t *testing.T, e kong.IDFillable) { + svc := e.(*kong.Service) + require.NotNil(t, svc.ID) + const expectedID = "d9bee1f8-db6e-5a37-9281-fd4aca16dc00" + require.Equal(t, expectedID, *svc.ID, "ID should be deterministic") + }, + }, + // Route + { + name: "route nil pointer", + entity: (*kong.Route)(nil), + expectErr: true, + }, + { + name: "route with nil name", + entity: &kong.Route{}, + expectErr: true, + }, + { + name: "route with empty name", + entity: &kong.Route{Name: kong.String("")}, + expectErr: true, + }, + { + name: "route with name", + entity: &kong.Route{ + Name: kong.String("some.service.name"), + }, + assertEntity: func(t *testing.T, e kong.IDFillable) { + route := e.(*kong.Route) + require.NotNil(t, route.ID) + + const expectedID = "7f0753cd-bff2-5f74-85b0-e8e2057d9500" + require.Equal(t, expectedID, *route.ID, "ID should be deterministic") + }, + }, + // Consumer + { + name: "consumer nil pointer", + entity: (*kong.Consumer)(nil), + expectErr: true, + }, + { + name: "consumer with nil username", + entity: &kong.Consumer{}, + expectErr: true, + }, + { + name: "consumer with empty username", + entity: &kong.Consumer{Username: kong.String("")}, + expectErr: true, + }, + { + name: "consumer with username", + entity: &kong.Consumer{ + Username: kong.String("some.username"), + }, + assertEntity: func(t *testing.T, e kong.IDFillable) { + consumer := e.(*kong.Consumer) + require.NotNil(t, consumer.ID) + + const expectedID = "782780c4-d345-5682-bdf6-722dccaf93e0" + require.Equal(t, expectedID, *consumer.ID, "ID should be deterministic") + }, + }, + } + + for _, tc := range testCases { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := tc.entity.FillID() + if tc.expectErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + tc.assertEntity(t, tc.entity) + }) + } +}