diff --git a/go.mod b/go.mod index 4bee5a971..507aa9ce5 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.19 replace github.com/imdario/mergo v0.3.12 => github.com/Kong/mergo v0.3.13 require ( + github.com/gofrs/uuid v4.4.0+incompatible github.com/google/go-cmp v0.5.9 github.com/google/go-querystring v1.1.0 github.com/google/uuid v1.3.0 diff --git a/go.sum b/go.sum index e463b3c5b..0cb6ce22e 100644 --- a/go.sum +++ b/go.sum @@ -25,6 +25,8 @@ github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXym github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/go-openapi/swag v0.19.14 h1:gm3vOOXfiuw5i9p5N9xJvfjvuofpyvLA9Wr6QfK5Fng= github.com/go-openapi/swag v0.19.14/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= +github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= diff --git a/kong/ids.go b/kong/ids.go new file mode 100644 index 000000000..c2c91f1d2 --- /dev/null +++ b/kong/ids.go @@ -0,0 +1,68 @@ +package kong + +import ( + "fmt" + + "github.com/gofrs/uuid" +) + +// IDFillableEntitiesT is a type constraint for entities that can be filled with an ID. +type IDFillableEntitiesT interface { + Service | Route +} + +// FillEntityID 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 UUIDv5 is: +// - for Service: Service.Name +// - for Route: Route.Name +func FillEntityID[T IDFillableEntitiesT](entity *T) error { + switch e := any(entity).(type) { + case *Service: + return fillIDForService(e) + case *Route: + return fillIDForRoute(e) + default: + return fmt.Errorf("unsupported entity: '%T'", entity) + } +} + +// The following variables are UUIDv5 namespaces used to generate IDs for entities, one per each entity type. +var ( + serviceIDsNamespace = uuid.Must(uuid.FromString("497d0cb2-5630-40a7-9858-8f210e6295f4")) + routeIDsNamespace = uuid.Must(uuid.FromString("6324c31d-0568-480f-8d8c-a5c1f4c8eb6c")) +) + +func fillIDForService(svc *Service) error { + if svc == nil { + return fmt.Errorf("service is nil") + } + if svc.ID != nil { + // ID already set, do nothing. + return nil + } + if svc.Name == nil || *svc.Name == "" { + return fmt.Errorf("service name is required") + } + + id := uuid.NewV5(serviceIDsNamespace, *svc.Name) + svc.ID = String(id.String()) + return nil +} + +func fillIDForRoute(route *Route) error { + if route == nil { + return fmt.Errorf("route is nil") + } + if route.ID != nil { + // ID already set, do nothing. + return nil + } + if route.Name == nil || *route.Name == "" { + return fmt.Errorf("route name is required") + } + + id := uuid.NewV5(routeIDsNamespace, *route.Name) + route.ID = String(id.String()) + return nil +} diff --git a/kong/ids_test.go b/kong/ids_test.go new file mode 100644 index 000000000..7f1014d30 --- /dev/null +++ b/kong/ids_test.go @@ -0,0 +1,111 @@ +package kong_test + +import ( + "testing" + + "github.com/kong/go-kong/kong" + "github.com/stretchr/testify/require" +) + +type fillEntityIDTestCase[T kong.IDFillableEntitiesT] struct { + name string + entity *T + + assertEntity func(t *testing.T, entity *T) + expectErr bool +} + +func (tc fillEntityIDTestCase[T]) run(t *testing.T) { + t.Run(tc.name, func(t *testing.T) { + err := kong.FillEntityID(tc.entity) + if tc.expectErr { + require.Error(t, err) + return + } + require.NoError(t, err) + tc.assertEntity(t, tc.entity) + }) +} + +func TestFillEntityID(t *testing.T) { + t.Run("Service", func(t *testing.T) { + testCases := []fillEntityIDTestCase[kong.Service]{ + { + name: "service nil pointer", + entity: (*kong.Service)(nil), + expectErr: true, + }, + { + name: "service with nil name", + entity: &kong.Service{}, + assertEntity: func(t *testing.T, svc *kong.Service) { + require.Nil(t, svc.ID, "ID should not be set when name is nil") + }, + expectErr: true, + }, + { + name: "service with empty name", + entity: &kong.Service{Name: kong.String("")}, + assertEntity: func(t *testing.T, svc *kong.Service) { + require.Nil(t, svc.ID, "ID should not be set when name is empty") + }, + expectErr: true, + }, + { + name: "service with name", + entity: &kong.Service{ + Name: kong.String("some.service.name"), + }, + assertEntity: func(t *testing.T, svc *kong.Service) { + require.NotNil(t, svc.ID) + const expectedID = "14a84f13-96ef-5628-b300-8bcd5a509f9b" + require.Equal(t, expectedID, *svc.ID, "ID should be deterministic") + }, + }, + } + for _, tc := range testCases { + tc.run(t) + } + }) + + t.Run("Route", func(t *testing.T) { + testCases := []fillEntityIDTestCase[kong.Route]{ + { + name: "route nil pointer", + entity: (*kong.Route)(nil), + expectErr: true, + }, + { + name: "route with nil name", + entity: &kong.Route{}, + assertEntity: func(t *testing.T, route *kong.Route) { + require.Nil(t, route.ID, "ID should not be set when name is nil") + }, + expectErr: true, + }, + { + name: "route with empty name", + entity: &kong.Route{Name: kong.String("")}, + assertEntity: func(t *testing.T, route *kong.Route) { + require.Nil(t, route.ID, "ID should not be set when name is empty") + }, + expectErr: true, + }, + { + name: "route with name", + entity: &kong.Route{ + Name: kong.String("some.service.name"), + }, + assertEntity: func(t *testing.T, route *kong.Route) { + require.NotNil(t, route.ID) + + const expectedID = "9279da23-17de-5cca-b1da-a60d7cec6802" + require.Equal(t, expectedID, *route.ID, "ID should be deterministic") + }, + }, + } + for _, tc := range testCases { + tc.run(t) + } + }) +}