Preferences: Add API validation and update documentation (#116045)

This commit is contained in:
Stephanie Hingtgen
2026-01-09 03:57:15 -07:00
committed by GitHub
parent 368762c026
commit 5f8668b3aa
14 changed files with 142 additions and 44 deletions
@@ -25,7 +25,7 @@ Keys:
- **theme** - One of: `light`, `dark`, or an empty string for the default theme
- **homeDashboardId** - Deprecated. Use `homeDashboardUID` instead.
- **homeDashboardUID**: The `:uid` of a dashboard
- **timezone** - One of: `utc`, `browser`, or an empty string for the default
- **timezone** - Any valid IANA timezone string (e.g., `America/New_York`, `Europe/London`), `utc`, `browser`, or an empty string for the default.
Omitting a key will cause the current value to be replaced with the
system default value.
@@ -5312,7 +5312,8 @@ export type PatchPrefsCmd = {
queryHistory?: QueryHistoryPreference;
regionalFormat?: string;
theme?: 'light' | 'dark';
timezone?: 'utc' | 'browser';
/** Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string */
timezone?: string;
weekStart?: string;
};
export type UpdatePrefsCmd = {
@@ -5325,7 +5326,8 @@ export type UpdatePrefsCmd = {
queryHistory?: QueryHistoryPreference;
regionalFormat?: string;
theme?: 'light' | 'dark' | 'system';
timezone?: 'utc' | 'browser';
/** Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string */
timezone?: string;
weekStart?: string;
};
export type OrgUserDto = {
@@ -86,7 +86,8 @@ export type PatchPrefsCmd = {
queryHistory?: QueryHistoryPreference;
regionalFormat?: string;
theme?: 'light' | 'dark';
timezone?: 'utc' | 'browser';
/** Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string */
timezone?: string;
weekStart?: string;
};
export type UpdatePrefsCmd = {
@@ -99,7 +100,8 @@ export type UpdatePrefsCmd = {
queryHistory?: QueryHistoryPreference;
regionalFormat?: string;
theme?: 'light' | 'dark' | 'system';
timezone?: 'utc' | 'browser';
/** Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string */
timezone?: string;
weekStart?: string;
};
export const {
+2 -2
View File
@@ -13,7 +13,7 @@ type UpdatePrefsCmd struct {
// Deprecated: Use HomeDashboardUID instead
HomeDashboardID int64 `json:"homeDashboardId"`
HomeDashboardUID *string `json:"homeDashboardUID,omitempty"`
// Enum: utc,browser
// Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string
Timezone string `json:"timezone"`
WeekStart string `json:"weekStart"`
QueryHistory *pref.QueryHistoryPreference `json:"queryHistory,omitempty"`
@@ -31,7 +31,7 @@ type PatchPrefsCmd struct {
// Default:0
// Deprecated: Use HomeDashboardUID instead
HomeDashboardID *int64 `json:"homeDashboardId,omitempty"`
// Enum: utc,browser
// Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string
Timezone *string `json:"timezone,omitempty"`
WeekStart *string `json:"weekStart,omitempty"`
Language *string `json:"language,omitempty"`
+4
View File
@@ -134,6 +134,10 @@ func (hs *HTTPServer) patchPreferencesFor(ctx context.Context, orgID, userID, te
return response.Error(http.StatusBadRequest, "Invalid theme", nil)
}
if dtoCmd.Timezone != nil && !pref.IsValidTimezone(*dtoCmd.Timezone) {
return response.Error(http.StatusBadRequest, "Invalid timezone. Must be a valid IANA timezone (e.g., America/New_York), 'utc', 'browser', or empty string", nil)
}
// convert dashboard UID to ID in order to store internally if it exists in the query, otherwise take the id from query
// nolint:staticcheck
dashboardID := dtoCmd.HomeDashboardID
@@ -208,6 +208,11 @@ func (s *preferenceStorage) save(ctx context.Context, obj runtime.Object) (runti
// Create implements rest.Creater.
func (s *preferenceStorage) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
if createValidation != nil {
if err := createValidation(ctx, obj); err != nil {
return nil, err
}
}
return s.save(ctx, obj)
}
@@ -223,6 +228,12 @@ func (s *preferenceStorage) Update(ctx context.Context, name string, objInfo res
return nil, false, err
}
if updateValidation != nil {
if err := updateValidation(ctx, obj, old); err != nil {
return nil, false, err
}
}
obj, err = s.save(ctx, obj)
return obj, false, err
}
+35 -1
View File
@@ -1,9 +1,14 @@
package preferences
import (
"context"
"fmt"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/admission"
"k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/apiserver/pkg/registry/rest"
genericapiserver "k8s.io/apiserver/pkg/server"
@@ -24,7 +29,8 @@ import (
)
var (
_ builder.APIGroupBuilder = (*APIBuilder)(nil)
_ builder.APIGroupBuilder = (*APIBuilder)(nil)
_ builder.APIGroupValidation = (*APIBuilder)(nil)
)
type APIBuilder struct {
@@ -108,3 +114,31 @@ func (b *APIBuilder) GetAPIRoutes(gv schema.GroupVersion) *builder.APIRoutes {
defs := b.GetOpenAPIDefinitions()(func(path string) spec.Ref { return spec.Ref{} })
return b.merger.GetAPIRoutes(defs)
}
// Validate validates that the preference object has valid theme and timezone (if specified)
func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) error {
if a.GetResource().Resource != "preferences" {
return nil
}
op := a.GetOperation()
if op != admission.Create && op != admission.Update {
return nil
}
obj := a.GetObject()
p, ok := obj.(*preferences.Preferences)
if !ok {
return apierrors.NewBadRequest(fmt.Sprintf("expected Preferences object, got %T", obj))
}
if p.Spec.Timezone != nil && !pref.IsValidTimezone(*p.Spec.Timezone) {
return apierrors.NewBadRequest("invalid timezone: must be a valid IANA timezone (e.g., America/New_York), 'utc', 'browser', or empty string")
}
if p.Spec.Theme != nil && *p.Spec.Theme != "" && !pref.IsValidThemeID(*p.Spec.Theme) {
return apierrors.NewBadRequest("invalid theme")
}
return nil
}
+4
View File
@@ -20,6 +20,10 @@ func UpdatePreferencesFor(ctx context.Context,
return response.Error(http.StatusBadRequest, "Invalid theme", nil)
}
if !pref.IsValidTimezone(dtoCmd.Timezone) {
return response.Error(http.StatusBadRequest, "Invalid timezone. Must be a valid IANA timezone (e.g., America/New_York), 'utc', 'browser', or empty string", nil)
}
// convert dashboard UID to ID in order to store internally if it exists in the query, otherwise take the id from query
// nolint:staticcheck
dashboardID := dtoCmd.HomeDashboardID
+21
View File
@@ -0,0 +1,21 @@
package pref
import (
"time"
)
// IsValidTimezone checks if the timezone string is valid.
// It accepts:
// - "" - uses default
// - "utc"
// - "browser"
// - Any valid IANA timezone (e.g., "America/New_York", "Europe/London")
func IsValidTimezone(timezone string) bool {
if timezone == "" || timezone == "utc" || timezone == "browser" {
return true
}
// try to load as IANA timezone
_, err := time.LoadLocation(timezone)
return err == nil
}
+38
View File
@@ -0,0 +1,38 @@
package pref
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestIsValidTimezone(t *testing.T) {
tests := []struct {
timezone string
valid bool
}{
{
timezone: "utc",
valid: true,
},
{
timezone: "browser",
valid: true,
},
{
timezone: "Europe/London",
valid: true,
},
{
timezone: "invalid",
valid: false,
},
{
timezone: "",
valid: true,
},
}
for _, test := range tests {
assert.Equal(t, test.valid, IsValidTimezone(test.timezone))
}
}
@@ -67,7 +67,7 @@ func TestIntegrationPreferences(t *testing.T) {
Path: fmt.Sprintf("/api/teams/%d/preferences", helper.Org1.Staff.ID),
Body: []byte(`{
"weekStart": "sunday",
"timezone": "africa"
"timezone": "Africa/Johannesburg"
}`),
}, &raw)
require.Equal(t, http.StatusOK, legacyResponse.Response.StatusCode, "create preference for user")
@@ -79,7 +79,7 @@ func TestIntegrationPreferences(t *testing.T) {
Path: "/api/org/preferences",
Body: []byte(`{
"weekStart": "sunday",
"timezone": "africa",
"timezone": "Africa/Accra",
"theme": "dark"
}`),
}, &raw)
@@ -144,7 +144,7 @@ func TestIntegrationPreferences(t *testing.T) {
jj, _ = json.Marshal(bootdata.Result.User)
require.JSONEq(t, `{
"timezone":"africa",
"timezone":"Africa/Johannesburg",
"weekStart":"saturday",
"theme":"dark",
"language":"en-US", `+ // FROM global default!
@@ -157,10 +157,10 @@ func TestIntegrationPreferences(t *testing.T) {
Path: "/apis/preferences.grafana.app/v1alpha1/namespaces/default/preferences/merged",
}, &preferences.Preferences{})
require.Equal(t, http.StatusOK, merged.Response.StatusCode, "get merged preferences")
require.Equal(t, "saturday", *merged.Result.Spec.WeekStart) // from user
require.Equal(t, "africa", *merged.Result.Spec.Timezone) // from team
require.Equal(t, "dark", *merged.Result.Spec.Theme) // from org
require.Equal(t, "en-US", *merged.Result.Spec.Language) // settings.ini
require.Equal(t, "dd/mm/yyyy", *merged.Result.Spec.RegionalFormat) // from user update
require.Equal(t, "saturday", *merged.Result.Spec.WeekStart) // from user
require.Equal(t, "Africa/Johannesburg", *merged.Result.Spec.Timezone) // from team
require.Equal(t, "dark", *merged.Result.Spec.Theme) // from org
require.Equal(t, "en-US", *merged.Result.Spec.Language) // settings.ini
require.Equal(t, "dd/mm/yyyy", *merged.Result.Spec.RegionalFormat) // from user update
})
}
+4 -10
View File
@@ -6152,11 +6152,8 @@
]
},
"timezone": {
"type": "string",
"enum": [
"utc",
"browser"
]
"description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string",
"type": "string"
},
"weekStart": {
"type": "string"
@@ -8657,11 +8654,8 @@
]
},
"timezone": {
"type": "string",
"enum": [
"utc",
"browser"
]
"description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string",
"type": "string"
},
"weekStart": {
"type": "string"
+4 -10
View File
@@ -18729,11 +18729,8 @@
]
},
"timezone": {
"type": "string",
"enum": [
"utc",
"browser"
]
"description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string",
"type": "string"
},
"weekStart": {
"type": "string"
@@ -23120,11 +23117,8 @@
]
},
"timezone": {
"type": "string",
"enum": [
"utc",
"browser"
]
"description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string",
"type": "string"
},
"weekStart": {
"type": "string"
+2 -8
View File
@@ -8264,10 +8264,7 @@
"type": "string"
},
"timezone": {
"enum": [
"utc",
"browser"
],
"description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string",
"type": "string"
},
"weekStart": {
@@ -12654,10 +12651,7 @@
"type": "string"
},
"timezone": {
"enum": [
"utc",
"browser"
],
"description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string",
"type": "string"
},
"weekStart": {