fix(api): update UID compatibility

Use the original UID format consistently across API, username, and store validation so UUID-based callers continue to work. Regenerate API documentation and add regression coverage for UUID memo IDs.
This commit is contained in:
boojack
2026-07-20 19:41:55 +08:00
parent d149a96566
commit b7d5d09f8a
22 changed files with 83 additions and 80 deletions
+1 -5
View File
@@ -3,9 +3,5 @@ package base
import "regexp"
var (
// ResourceIDMatcher matches new user-provided resource IDs per AIP-122.
ResourceIDMatcher = regexp.MustCompile(`^[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$`)
// UIDMatcher also accepts the previous 36-character format so existing
// generated and persisted UIDs remain writable by the store layer.
UIDMatcher = regexp.MustCompile(`^(?:[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?|[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?)$`)
UIDMatcher = regexp.MustCompile(`^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$`)
)
+2 -27
View File
@@ -1,7 +1,6 @@
package base
import (
"strings"
"testing"
)
@@ -24,8 +23,8 @@ func TestUIDMatcher(t *testing.T) {
{"abc123", true},
{"abc123-", false},
{"123e4567-e89b-12d3-a456-426614174000", true}, // UUID v4 from IDP
{"a123456789012345678901234567890123456", true}, // New AIP-compatible IDs can exceed the legacy limit.
{"A123456789012345678901234567890123456", false}, // Legacy uppercase IDs remain capped at 36 characters.
{"a12345678901234567890123456789012345", true}, // 36 characters.
{"a123456789012345678901234567890123456", false}, // 37 characters.
}
for _, test := range tests {
@@ -37,27 +36,3 @@ func TestUIDMatcher(t *testing.T) {
})
}
}
func TestResourceIDMatcher(t *testing.T) {
tests := []struct {
input string
expected bool
}{
{"a", true},
{"abc-123", true},
{"a" + strings.Repeat("b", 62), true},
{"a" + strings.Repeat("b", 63), false},
{"1abc", false},
{"Abc", false},
{"abc-", false},
{"abc_def", false},
}
for _, test := range tests {
t.Run(test.input, func(t *testing.T) {
if got := ResourceIDMatcher.MatchString(test.input); got != test.expected {
t.Errorf("ResourceIDMatcher.MatchString(%q) = %v, want %v", test.input, got, test.expected)
}
})
}
}
+1 -1
View File
@@ -117,7 +117,7 @@ message CreateAttachmentRequest {
// Optional. The attachment ID to use for this attachment.
// If empty, a unique ID will be generated.
// Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
// Format: ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$
string attachment_id = 2 [(google.api.field_behavior) = OPTIONAL];
}
+1 -1
View File
@@ -125,7 +125,7 @@ message CreateIdentityProviderRequest {
// Optional. The ID to use for the identity provider, which will become the final component of the resource name.
// If not provided, the system will generate one.
// Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
// Format: ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$
string identity_provider_id = 2 [(google.api.field_behavior) = OPTIONAL];
}
+2 -2
View File
@@ -205,7 +205,7 @@ message Memo {
};
// The resource name of the memo.
// Format: memos/{memo}, memo is the user defined id or uuid.
// Format: memos/{memo}, where memo is the user-defined UID.
string name = 1 [(google.api.field_behavior) = IDENTIFIER];
// The state of the memo.
@@ -296,7 +296,7 @@ message CreateMemoRequest {
// Optional. The memo ID to use for this memo.
// If empty, a unique ID will be generated.
// Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
// Format: ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$
string memo_id = 2 [(google.api.field_behavior) = OPTIONAL];
}
+2 -2
View File
@@ -312,8 +312,8 @@ message CreateUserRequest {
];
// Optional. The resource ID to use for this user. If set, it must equal
// user.username and follow the User resource ID format.
// Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
// user.username, follow the UID format, and not consist entirely of digits.
// Format: ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$
string user_id = 2 [(google.api.field_behavior) = OPTIONAL];
// Optional. If set, validate the request but don't actually create the user.
+1 -1
View File
@@ -327,7 +327,7 @@ type CreateAttachmentRequest struct {
Attachment *Attachment `protobuf:"bytes,1,opt,name=attachment,proto3" json:"attachment,omitempty"`
// Optional. The attachment ID to use for this attachment.
// If empty, a unique ID will be generated.
// Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
// Format: ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$
AttachmentId string `protobuf:"bytes,2,opt,name=attachment_id,json=attachmentId,proto3" json:"attachment_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
+1 -1
View File
@@ -512,7 +512,7 @@ type CreateIdentityProviderRequest struct {
IdentityProvider *IdentityProvider `protobuf:"bytes,1,opt,name=identity_provider,json=identityProvider,proto3" json:"identity_provider,omitempty"`
// Optional. The ID to use for the identity provider, which will become the final component of the resource name.
// If not provided, the system will generate one.
// Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
// Format: ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$
IdentityProviderId string `protobuf:"bytes,2,opt,name=identity_provider_id,json=identityProviderId,proto3" json:"identity_provider_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
+2 -2
View File
@@ -219,7 +219,7 @@ func (x *Reaction) GetCreateTime() *timestamppb.Timestamp {
type Memo struct {
state protoimpl.MessageState `protogen:"open.v1"`
// The resource name of the memo.
// Format: memos/{memo}, memo is the user defined id or uuid.
// Format: memos/{memo}, where memo is the user-defined UID.
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
// The state of the memo.
State State `protobuf:"varint,2,opt,name=state,proto3,enum=memos.api.v1.State" json:"state,omitempty"`
@@ -472,7 +472,7 @@ type CreateMemoRequest struct {
Memo *Memo `protobuf:"bytes,1,opt,name=memo,proto3" json:"memo,omitempty"`
// Optional. The memo ID to use for this memo.
// If empty, a unique ID will be generated.
// Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
// Format: ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$
MemoId string `protobuf:"bytes,2,opt,name=memo_id,json=memoId,proto3" json:"memo_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
+2 -2
View File
@@ -651,8 +651,8 @@ type CreateUserRequest struct {
// Required. The user to create.
User *User `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"`
// Optional. The resource ID to use for this user. If set, it must equal
// user.username and follow the User resource ID format.
// Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
// user.username, follow the UID format, and not consist entirely of digits.
// Format: ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$
UserId string `protobuf:"bytes,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
// Optional. If set, validate the request but don't actually create the user.
ValidateOnly bool `protobuf:"varint,3,opt,name=validate_only,json=validateOnly,proto3" json:"validate_only,omitempty"`
+6 -6
View File
@@ -95,7 +95,7 @@ paths:
description: |-
Optional. The attachment ID to use for this attachment.
If empty, a unique ID will be generated.
Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
Format: ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$
schema:
type: string
requestBody:
@@ -350,7 +350,7 @@ paths:
description: |-
Optional. The ID to use for the identity provider, which will become the final component of the resource name.
If not provided, the system will generate one.
Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
Format: ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$
schema:
type: string
requestBody:
@@ -705,7 +705,7 @@ paths:
description: |-
Optional. The memo ID to use for this memo.
If empty, a unique ID will be generated.
Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
Format: ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$
schema:
type: string
requestBody:
@@ -1355,8 +1355,8 @@ paths:
in: query
description: |-
Optional. The resource ID to use for this user. If set, it must equal
user.username and follow the User resource ID format.
Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
user.username, follow the UID format, and not consist entirely of digits.
Format: ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$
schema:
type: string
- name: validateOnly
@@ -3230,7 +3230,7 @@ components:
type: string
description: |-
The resource name of the memo.
Format: memos/{memo}, memo is the user defined id or uuid.
Format: memos/{memo}, where memo is the user-defined UID.
state:
enum:
- STATE_UNSPECIFIED
+3 -3
View File
@@ -136,14 +136,14 @@ func ExtractIdentityProviderUIDFromName(name string) (string, error) {
// ValidateAndGenerateUID validates a user-provided UID or generates a new one.
// If provided is empty, a new shortuuid is generated.
// If provided is non-empty, it is validated as a user-provided resource ID.
// If provided is non-empty, it is validated against base.UIDMatcher.
func ValidateAndGenerateUID(provided string) (string, error) {
uid := strings.TrimSpace(provided)
if uid == "" {
return shortuuid.New(), nil
}
if !base.ResourceIDMatcher.MatchString(uid) {
return "", status.Errorf(codes.InvalidArgument, "invalid resource ID: must be 1-63 characters, start with a lowercase letter, contain only lowercase letters, digits, or hyphens, and end with a letter or digit")
if !base.UIDMatcher.MatchString(uid) {
return "", status.Errorf(codes.InvalidArgument, "invalid UID: must be 1-36 characters, contain only letters, digits, or hyphens, and start and end with a letter or digit")
}
return uid, nil
}
+5 -4
View File
@@ -12,10 +12,11 @@ func TestValidateAndGenerateUIDValidatesUserProvidedResourceIDs(t *testing.T) {
wantError bool
}{
{name: "lowercase", provided: "memo-1"},
{name: "maximum length", provided: "a" + strings.Repeat("b", 62)},
{name: "digit first", provided: "1-memo", wantError: true},
{name: "uppercase", provided: "Memo", wantError: true},
{name: "too long", provided: "a" + strings.Repeat("b", 63), wantError: true},
{name: "UUID", provided: "21ec98aa-9a8f-458c-a2a3-c7dc69b6f591"},
{name: "maximum length", provided: "a" + strings.Repeat("b", 35)},
{name: "digit first", provided: "1-memo"},
{name: "uppercase", provided: "Memo"},
{name: "too long", provided: "a" + strings.Repeat("b", 36), wantError: true},
}
for _, test := range tests {
+5 -4
View File
@@ -8,12 +8,13 @@ import (
// deriveSSOUsername produces the local username for a new SSO-created user.
//
// The current policy prefixes a UUID with a letter so the generated value
// follows the same AIP-compatible format as user-selected usernames.
// The current policy is to use a standard UUID string directly. This keeps the
// username independent of IdP profile fields and avoids availability probes or
// retry loops around concurrent first-time logins.
func deriveSSOUsername() (string, error) {
username := "user-" + util.GenUUID()
username := util.GenUUID()
if err := validateWritableUsername(username); err != nil {
return "", errors.Wrap(err, "generated username did not satisfy username constraints")
return "", errors.Wrap(err, "generated UUID did not satisfy username constraints")
}
return username, nil
}
+2 -3
View File
@@ -1,7 +1,6 @@
package v1
import (
"strings"
"testing"
)
@@ -10,8 +9,8 @@ func TestDeriveSSOUsername(t *testing.T) {
if err != nil {
t.Fatalf("deriveSSOUsername() returned error: %v", err)
}
if !strings.HasPrefix(username, "user-") {
t.Fatalf("deriveSSOUsername() = %q, want user- prefix", username)
if len(username) != 36 {
t.Fatalf("deriveSSOUsername() = %q, want a 36-character UUID", username)
}
if err := validateWritableUsername(username); err != nil {
t.Fatalf("deriveSSOUsername() produced invalid username %q: %v", username, err)
@@ -17,6 +17,27 @@ import (
"github.com/usememos/memos/store"
)
func TestCreateMemoAcceptsUUID(t *testing.T) {
ctx := context.Background()
ts := NewTestService(t)
defer ts.Cleanup()
user, err := ts.CreateRegularUser(ctx, "test-user")
require.NoError(t, err)
userCtx := ts.CreateUserContext(ctx, user.ID)
const memoID = "21ec98aa-9a8f-458c-a2a3-c7dc69b6f591"
memo, err := ts.Service.CreateMemo(userCtx, &apiv1.CreateMemoRequest{
Memo: &apiv1.Memo{
Content: "Created with a UUID",
Visibility: apiv1.Visibility_PRIVATE,
},
MemoId: memoID,
})
require.NoError(t, err)
require.Equal(t, "memos/"+memoID, memo.Name)
}
func TestListMemos(t *testing.T) {
ctx := context.Background()
+14 -2
View File
@@ -27,12 +27,24 @@ func parseUsernameFromName(name string) (string, error) {
}
func validateWritableUsername(username string) error {
if !base.ResourceIDMatcher.MatchString(username) {
return errors.New("invalid username: must be 1-63 characters, start with a lowercase letter, contain only lowercase letters, digits, or hyphens, and end with a letter or digit")
if username == "" || isNumericUsername(username) || !base.UIDMatcher.MatchString(username) {
return errors.Errorf("invalid username %q", username)
}
return nil
}
func isNumericUsername(username string) bool {
if username == "" {
return false
}
for _, char := range username {
if char < '0' || char > '9' {
return false
}
}
return true
}
// ResolveUserByName resolves a username-based user resource name to a store user.
func ResolveUserByName(ctx context.Context, stores *store.Store, name string) (*store.User, error) {
username, err := parseUsernameFromName(name)
@@ -16,9 +16,8 @@ func TestValidateWritableUsername(t *testing.T) {
username: "alice",
},
{
name: "mixed case",
username: "Alice",
wantError: true,
name: "mixed case",
username: "Alice",
},
{
name: "hyphenated",
@@ -30,17 +29,16 @@ func TestValidateWritableUsername(t *testing.T) {
},
{
name: "maximum length",
username: "a" + strings.Repeat("b", 62),
username: "a" + strings.Repeat("b", 35),
},
{
name: "too long",
username: "a" + strings.Repeat("b", 63),
username: "a" + strings.Repeat("b", 36),
wantError: true,
},
{
name: "digit first",
username: "1alice",
wantError: true,
name: "digit first",
username: "1alice",
},
{
name: "hyphen last",
@@ -146,7 +146,7 @@ export type CreateAttachmentRequest = Message<"memos.api.v1.CreateAttachmentRequ
/**
* Optional. The attachment ID to use for this attachment.
* If empty, a unique ID will be generated.
* Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
* Format: ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$
*
* @generated from field: string attachment_id = 2;
*/
+1 -1
View File
@@ -257,7 +257,7 @@ export type CreateIdentityProviderRequest = Message<"memos.api.v1.CreateIdentity
/**
* Optional. The ID to use for the identity provider, which will become the final component of the resource name.
* If not provided, the system will generate one.
* Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
* Format: ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$
*
* @generated from field: string identity_provider_id = 2;
*/
@@ -79,7 +79,7 @@ export const ReactionSchema: GenMessage<Reaction> = /*@__PURE__*/
export type Memo = Message<"memos.api.v1.Memo"> & {
/**
* The resource name of the memo.
* Format: memos/{memo}, memo is the user defined id or uuid.
* Format: memos/{memo}, where memo is the user-defined UID.
*
* @generated from field: string name = 1;
*/
@@ -292,7 +292,7 @@ export type CreateMemoRequest = Message<"memos.api.v1.CreateMemoRequest"> & {
/**
* Optional. The memo ID to use for this memo.
* If empty, a unique ID will be generated.
* Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
* Format: ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$
*
* @generated from field: string memo_id = 2;
*/
@@ -293,8 +293,8 @@ export type CreateUserRequest = Message<"memos.api.v1.CreateUserRequest"> & {
/**
* Optional. The resource ID to use for this user. If set, it must equal
* user.username and follow the User resource ID format.
* Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
* user.username, follow the UID format, and not consist entirely of digits.
* Format: ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$
*
* @generated from field: string user_id = 2;
*/