Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion liquid/info.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"encoding/json"
"slices"

. "github.com/majewsky/gg/option"

"github.com/sapcc/go-api-declarations/internal/clone"
)

Expand All @@ -28,6 +30,10 @@ type ServiceInfo struct {
// The display name can be used in user-facing messages or interfaces to refer to the service.
DisplayName string `json:"displayName"`

// Info for each category that can group resources and rates of this service.
// The default category (see liquid.DefaultCategoryName) need not, and may not be declared here.
Categories map[CategoryName]CategoryInfo `json:"categories"`

// Info for each resource that this service provides.
Resources map[ResourceName]ResourceInfo `json:"resources"`

Expand Down Expand Up @@ -57,15 +63,48 @@ func (i ServiceInfo) Clone() ServiceInfo {
cloned.Rates = clone.MapRecursively(i.Rates)
cloned.CapacityMetricFamilies = clone.MapRecursively(i.CapacityMetricFamilies)
cloned.UsageMetricFamilies = clone.MapRecursively(i.UsageMetricFamilies)
cloned.Categories = clone.MapRecursively(i.Categories)
return cloned
}

// CategoryName is a name of a category that can group resources and rates.
// It appears in type ServiceInfo, ResourceInfo and RateInfo.
type CategoryName string

// DefaultCategoryName is a reserved category name that can be used for resources and rates that
// do not belong to any specific category.
const DefaultCategoryName CategoryName = "default"

// IsValid returns whether a CategoryName is valid.
// This can be used to check unmarshalled values.
func (c CategoryName) IsValid() bool {
return c != ""
}

// CategoryInfo describes a category that can group resources and rates of a liquid's service.
// This type appears in type ServiceInfo.
type CategoryInfo struct {
DisplayName string `json:"displayName"`
}

// Clone returns a deep copy of the given CategoryInfo.
func (c CategoryInfo) Clone() CategoryInfo {
// this method is only offered for compatibility with future expansion;
// right now, all fields are copied by-value automatically
return c
}

// ResourceInfo describes a resource that a liquid's service provides.
// This type appears in type ServiceInfo.
type ResourceInfo struct {
// The display name can be used in user-facing messages or interfaces to refer to the resource.
DisplayName string `json:"displayName"`

// Category references one entry of ServiceInfo.Categories.
// It can be used in user-facing messages or interfaces to group resources of one service into subgroups.
// If None, the resource is grouped into the implicitly-defined default category.
Category Option[CategoryName] `json:"categoryName,omitzero"`

// If omitted or empty, the resource is "countable" and any quota or usage values describe a number of objects.
// If non-empty, the resource is "measured" and quota or usage values are in multiples of the given unit.
// For example, the compute resource "cores" is countable, but the compute resource "ram" is measured, usually in MiB.
Expand Down Expand Up @@ -106,7 +145,7 @@ func (i ResourceInfo) Clone() ResourceInfo {
}

// Topology describes how capacity and usage reported by a certain resource is structured.
// Type type appears in type ResourceInfo.
// It appears in type ResourceInfo.
type Topology string

const (
Expand Down Expand Up @@ -165,6 +204,11 @@ type RateInfo struct {
// The display name can be used in user-facing messages or interfaces to refer to the rate.
DisplayName string `json:"displayName"`

// Category references one entry of ServiceInfo.Categories.
// It can be used in user-facing messages or interfaces to group rates of one service into subgroups.
// If None, the resource is grouped into the implicitly-defined default category.
Category Option[CategoryName] `json:"categoryName,omitzero"`

// If omitted or empty, the rate is "countable" and usage values describe a number of events.
// If non-empty, the rate is "measured" and usage values are in multiples of the given unit.
// For example, the storage rate "volume_creations" is countable, but the network rate "outbound_transfer" is measured, e.g. in bytes.
Expand Down
8 changes: 8 additions & 0 deletions liquid/info_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,23 @@ import (
"testing"

th "github.com/sapcc/go-api-declarations/internal/testhelper"

. "github.com/majewsky/gg/option"
)

func TestCloneServiceInfo(t *testing.T) {
// this dummy info sets all possible fields in order to test cloning of all levels
info := ServiceInfo{
Version: 42,
DisplayName: "Test Service",
Categories: map[CategoryName]CategoryInfo{
"cat1": {DisplayName: "Category 1"},
"cat2": {DisplayName: "Category 2"},
},
Resources: map[ResourceName]ResourceInfo{
"capacity": {
DisplayName: "Capacity",
Category: Some(CategoryName("cat1")),
Unit: UnitBytes,
Topology: AZAwareTopology,
HasCapacity: true,
Expand All @@ -30,6 +37,7 @@ func TestCloneServiceInfo(t *testing.T) {
Rates: map[RateName]RateInfo{
"thing_creations": {
DisplayName: "Thing Creations",
Category: Some(CategoryName("cat2")),
Unit: UnitNone,
Topology: FlatTopology,
HasUsage: true,
Expand Down
47 changes: 44 additions & 3 deletions liquid/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,25 +38,66 @@ func ValidateServiceInfo(srv ServiceInfo) error {
}

func validateServiceInfoImpl(srv ServiceInfo) (errs errorset.ErrorSet) {
categorySeen := make(map[CategoryName]struct{})
for _, resName := range slices.Sorted(maps.Keys(srv.Resources)) {
res := srv.Resources[resName]
if !resName.IsValid() {
errs.Addf(".Resources[%q] has invalid name (must match /%s/)", resName, identifierRx.String())
}
if !srv.Resources[resName].Topology.IsValid() {
if !res.Topology.IsValid() {
errs.Addf(".Resources[%q] has invalid topology %q", resName, srv.Resources[resName].Topology)
}
category, hasCategory := res.Category.Unpack()
if hasCategory {
categorySeen[category] = struct{}{}
}
if hasCategory && !category.IsValid() {
errs.Addf(".Resources[%q] has invalid category %q", resName, category)
continue // no further errs for this
}
if _, ok := srv.Categories[category]; hasCategory && !ok {
errs.Addf(".Resources[%q] has category %q, which is not declared in .Categories", resName, category)
}
}

for _, rateName := range slices.Sorted(maps.Keys(srv.Rates)) {
rate := srv.Rates[rateName]
if !rateName.IsValid() {
errs.Addf(".Rates[%q] has invalid name (must match /%s/)", rateName, identifierRx.String())
}
if !srv.Rates[rateName].Topology.IsValid() {
if !rate.Topology.IsValid() {
errs.Addf(".Rates[%q] has invalid topology %q", rateName, srv.Rates[rateName].Topology)
}
if !srv.Rates[rateName].HasUsage {
if !rate.HasUsage {
errs.Addf(".Rates[%q] declared with HasUsage = false, but must be true", rateName)
}
category, hasCategory := rate.Category.Unpack()
if hasCategory {
categorySeen[category] = struct{}{}
}
if hasCategory && !category.IsValid() {
errs.Addf(".Rates[%q] has invalid category %q", rateName, category)
continue // no further errs for this
}
if _, ok := srv.Categories[category]; hasCategory && !ok {
errs.Addf(".Rates[%q] has category %q, which is not declared in .Categories", rateName, category)
}
}

for categoryName, categoryInfo := range srv.Categories {
if categoryName == DefaultCategoryName {
errs.Addf(`.Categories[%q] has reserved identifier %q`, categoryName, DefaultCategoryName)
continue // no further errs for this
}
if !categoryName.IsValid() {
errs.Addf(".Categories[%q] has invalid identifier", categoryName)
}
if categoryInfo.DisplayName == "" {
errs.Addf(".Categories[%q] has invalid DisplayName", categoryName)
}
if _, ok := categorySeen[categoryName]; !ok {
errs.Addf(".Categories[%q] is not referenced by any resource or rate", categoryName)
}
}

return errs
Expand Down
52 changes: 43 additions & 9 deletions liquid/validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,26 +16,38 @@ import (

var serviceInfo = ServiceInfo{
Version: 73,
Categories: map[CategoryName]CategoryInfo{
"cat1": {
DisplayName: "Category 1",
},
"cat2": {
DisplayName: "Category 2",
},
},
Resources: map[ResourceName]ResourceInfo{
"foo": {
Category: Some(CategoryName("cat1")),
Unit: UnitNone,
Topology: AZAwareTopology,
HasCapacity: true,
HasQuota: true,
},
"bar": {
Category: Some(CategoryName("cat1")),
Unit: UnitNone,
Topology: FlatTopology,
HasCapacity: true,
HasQuota: true,
},
"baz": {
Category: Some(CategoryName("cat2")),
Unit: UnitNone,
Topology: FlatTopology,
HasCapacity: false,
HasQuota: false,
},
"qux": {
Category: Some(CategoryName("cat2")),
Unit: UnitNone,
Topology: AZSeparatedTopology,
HasCapacity: true,
Expand All @@ -50,16 +62,19 @@ var serviceInfo = ServiceInfo{
},
Rates: map[RateName]RateInfo{
"corge": {
Category: Some(CategoryName("cat1")),
Unit: UnitNone,
HasUsage: true,
Topology: AZAwareTopology,
},
"grault": {
Category: Some(CategoryName("cat1")),
Unit: UnitNone,
HasUsage: true,
Topology: FlatTopology,
},
"garply": {
Category: Some(CategoryName("cat2")),
Unit: UnitNone,
HasUsage: true,
Topology: AZAwareTopology,
Expand Down Expand Up @@ -94,18 +109,29 @@ var serviceInfo = ServiceInfo{

func TestValidateServiceInfo(t *testing.T) {
invalidServiceInfo := ServiceInfo{
Categories: map[CategoryName]CategoryInfo{
"default": {DisplayName: "Default"}, // Category name "default" is reserved
"valid": {DisplayName: "Valid"},
"extra": {DisplayName: "Extra"}, // This category is not used by any resource or rate which is forbidden
"": {DisplayName: "Empty"}, // Invalid category
"empty": {DisplayName: ""}, // Invalid category
},
Resources: map[ResourceName]ResourceInfo{
"foo": {}, // Topology is missing
"bar": {Topology: "InvalidTopology"},
"baz": {Topology: AZSeparatedTopology},
"foo+private": {Topology: FlatTopology}, // Invalid name
"foo": {Category: Some(CategoryName("empty"))}, // Topology is missing
"bar": {Category: Some(CategoryName("valid")), Topology: "InvalidTopology"},
"baz": {Category: Some(CategoryName("valid")), Topology: AZSeparatedTopology},
"foo+private": {Category: Some(CategoryName("valid")), Topology: FlatTopology}, // Invalid name
"qux1": {Category: Some(CategoryName("")), Topology: FlatTopology}, // Invalid category
"qux2": {Category: Some(CategoryName("someUnknownCategory")), Topology: FlatTopology}, // Unknown category
},
Rates: map[RateName]RateInfo{
"corge": {HasUsage: true}, // Topology is missing
"grault": {HasUsage: true, Topology: "InvalidTopology"},
"garply": {HasUsage: false, Topology: AZSeparatedTopology}, // HasUsage = false is not allowed
"waldo": {HasUsage: true, Topology: AZSeparatedTopology},
"foo/create": {HasUsage: true, Topology: FlatTopology}, // Invalid name
"corge": {Category: Some(CategoryName("empty")), HasUsage: true}, // Topology is missing
"grault": {Category: Some(CategoryName("valid")), HasUsage: true, Topology: "InvalidTopology"},
"garply": {Category: Some(CategoryName("valid")), HasUsage: false, Topology: AZSeparatedTopology}, // HasUsage = false is not allowed
"waldo": {Category: Some(CategoryName("valid")), HasUsage: true, Topology: AZSeparatedTopology},
"foo/create": {Category: Some(CategoryName("valid")), HasUsage: true, Topology: FlatTopology}, // Invalid name
"bla1": {Category: Some(CategoryName("")), HasUsage: true, Topology: FlatTopology}, // Invalid category
"bla2": {Category: Some(CategoryName("someUnknownCategory")), HasUsage: true, Topology: FlatTopology}, // Unknown category
},
}
expectedErrStrings := []string{
Expand All @@ -116,6 +142,14 @@ func TestValidateServiceInfo(t *testing.T) {
`.Rates["grault"] has invalid topology "InvalidTopology"`,
`.Rates["garply"] declared with HasUsage = false, but must be true`,
`.Rates["foo/create"] has invalid name (must match /^[a-zA-Z][a-zA-Z0-9._-]*$/)`,
`.Resources["qux1"] has invalid category ""`,
`.Resources["qux2"] has category "someUnknownCategory", which is not declared in .Categories`,
`.Rates["bla1"] has invalid category ""`,
`.Rates["bla2"] has category "someUnknownCategory", which is not declared in .Categories`,
`.Categories["default"] has reserved identifier "default"`,
`.Categories[""] has invalid identifier`,
`.Categories["extra"] is not referenced by any resource or rate`,
`.Categories["empty"] has invalid DisplayName`,
}
errs := validateServiceInfoImpl(invalidServiceInfo)
assertErrorSet(t, errs, expectedErrStrings)
Expand Down