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" import "regexp"
var ( var (
// ResourceIDMatcher matches new user-provided resource IDs per AIP-122. UIDMatcher = regexp.MustCompile(`^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$`)
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])?)$`)
) )
+2 -27
View File
@@ -1,7 +1,6 @@
package base package base
import ( import (
"strings"
"testing" "testing"
) )
@@ -24,8 +23,8 @@ func TestUIDMatcher(t *testing.T) {
{"abc123", true}, {"abc123", true},
{"abc123-", false}, {"abc123-", false},
{"123e4567-e89b-12d3-a456-426614174000", true}, // UUID v4 from IDP {"123e4567-e89b-12d3-a456-426614174000", true}, // UUID v4 from IDP
{"a123456789012345678901234567890123456", true}, // New AIP-compatible IDs can exceed the legacy limit. {"a12345678901234567890123456789012345", true}, // 36 characters.
{"A123456789012345678901234567890123456", false}, // Legacy uppercase IDs remain capped at 36 characters. {"a123456789012345678901234567890123456", false}, // 37 characters.
} }
for _, test := range tests { 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. // Optional. The attachment ID to use for this attachment.
// If empty, a unique ID will be generated. // 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]; 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. // 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. // 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]; 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. // 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]; string name = 1 [(google.api.field_behavior) = IDENTIFIER];
// The state of the memo. // The state of the memo.
@@ -296,7 +296,7 @@ message CreateMemoRequest {
// Optional. The memo ID to use for this memo. // Optional. The memo ID to use for this memo.
// If empty, a unique ID will be generated. // 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]; 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 // Optional. The resource ID to use for this user. If set, it must equal
// user.username and follow the User resource ID format. // user.username, follow the UID format, and not consist entirely of digits.
// 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 user_id = 2 [(google.api.field_behavior) = OPTIONAL]; string user_id = 2 [(google.api.field_behavior) = OPTIONAL];
// Optional. If set, validate the request but don't actually create the user. // 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"` Attachment *Attachment `protobuf:"bytes,1,opt,name=attachment,proto3" json:"attachment,omitempty"`
// Optional. The attachment ID to use for this attachment. // Optional. The attachment ID to use for this attachment.
// If empty, a unique ID will be generated. // 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"` AttachmentId string `protobuf:"bytes,2,opt,name=attachment_id,json=attachmentId,proto3" json:"attachment_id,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache 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"` 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. // 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. // 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"` IdentityProviderId string `protobuf:"bytes,2,opt,name=identity_provider_id,json=identityProviderId,proto3" json:"identity_provider_id,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
+2 -2
View File
@@ -219,7 +219,7 @@ func (x *Reaction) GetCreateTime() *timestamppb.Timestamp {
type Memo struct { type Memo struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
// The resource name of the 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.
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
// The state of the memo. // The state of the memo.
State State `protobuf:"varint,2,opt,name=state,proto3,enum=memos.api.v1.State" json:"state,omitempty"` 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"` Memo *Memo `protobuf:"bytes,1,opt,name=memo,proto3" json:"memo,omitempty"`
// Optional. The memo ID to use for this memo. // Optional. The memo ID to use for this memo.
// If empty, a unique ID will be generated. // 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"` MemoId string `protobuf:"bytes,2,opt,name=memo_id,json=memoId,proto3" json:"memo_id,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
+2 -2
View File
@@ -651,8 +651,8 @@ type CreateUserRequest struct {
// Required. The user to create. // Required. The user to create.
User *User `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"` 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 // Optional. The resource ID to use for this user. If set, it must equal
// user.username and follow the User resource ID format. // user.username, follow the UID format, and not consist entirely of digits.
// 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])?$
UserId string `protobuf:"bytes,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` 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. // 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"` 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: |- description: |-
Optional. The attachment ID to use for this attachment. Optional. The attachment ID to use for this attachment.
If empty, a unique ID will be generated. 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: schema:
type: string type: string
requestBody: requestBody:
@@ -350,7 +350,7 @@ paths:
description: |- description: |-
Optional. The ID to use for the identity provider, which will become the final component of the resource name. 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. 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: schema:
type: string type: string
requestBody: requestBody:
@@ -705,7 +705,7 @@ paths:
description: |- description: |-
Optional. The memo ID to use for this memo. Optional. The memo ID to use for this memo.
If empty, a unique ID will be generated. 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: schema:
type: string type: string
requestBody: requestBody:
@@ -1355,8 +1355,8 @@ paths:
in: query in: query
description: |- description: |-
Optional. The resource ID to use for this user. If set, it must equal Optional. The resource ID to use for this user. If set, it must equal
user.username and follow the User resource ID format. user.username, follow the UID format, and not consist entirely of digits.
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: schema:
type: string type: string
- name: validateOnly - name: validateOnly
@@ -3230,7 +3230,7 @@ components:
type: string type: string
description: |- description: |-
The resource name of the 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.
state: state:
enum: enum:
- STATE_UNSPECIFIED - 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. // ValidateAndGenerateUID validates a user-provided UID or generates a new one.
// If provided is empty, a new shortuuid is generated. // 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) { func ValidateAndGenerateUID(provided string) (string, error) {
uid := strings.TrimSpace(provided) uid := strings.TrimSpace(provided)
if uid == "" { if uid == "" {
return shortuuid.New(), nil return shortuuid.New(), nil
} }
if !base.ResourceIDMatcher.MatchString(uid) { if !base.UIDMatcher.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") 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 return uid, nil
} }
+5 -4
View File
@@ -12,10 +12,11 @@ func TestValidateAndGenerateUIDValidatesUserProvidedResourceIDs(t *testing.T) {
wantError bool wantError bool
}{ }{
{name: "lowercase", provided: "memo-1"}, {name: "lowercase", provided: "memo-1"},
{name: "maximum length", provided: "a" + strings.Repeat("b", 62)}, {name: "UUID", provided: "21ec98aa-9a8f-458c-a2a3-c7dc69b6f591"},
{name: "digit first", provided: "1-memo", wantError: true}, {name: "maximum length", provided: "a" + strings.Repeat("b", 35)},
{name: "uppercase", provided: "Memo", wantError: true}, {name: "digit first", provided: "1-memo"},
{name: "too long", provided: "a" + strings.Repeat("b", 63), wantError: true}, {name: "uppercase", provided: "Memo"},
{name: "too long", provided: "a" + strings.Repeat("b", 36), wantError: true},
} }
for _, test := range tests { for _, test := range tests {
+5 -4
View File
@@ -8,12 +8,13 @@ import (
// deriveSSOUsername produces the local username for a new SSO-created user. // deriveSSOUsername produces the local username for a new SSO-created user.
// //
// The current policy prefixes a UUID with a letter so the generated value // The current policy is to use a standard UUID string directly. This keeps the
// follows the same AIP-compatible format as user-selected usernames. // username independent of IdP profile fields and avoids availability probes or
// retry loops around concurrent first-time logins.
func deriveSSOUsername() (string, error) { func deriveSSOUsername() (string, error) {
username := "user-" + util.GenUUID() username := util.GenUUID()
if err := validateWritableUsername(username); err != nil { 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 return username, nil
} }
+2 -3
View File
@@ -1,7 +1,6 @@
package v1 package v1
import ( import (
"strings"
"testing" "testing"
) )
@@ -10,8 +9,8 @@ func TestDeriveSSOUsername(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("deriveSSOUsername() returned error: %v", err) t.Fatalf("deriveSSOUsername() returned error: %v", err)
} }
if !strings.HasPrefix(username, "user-") { if len(username) != 36 {
t.Fatalf("deriveSSOUsername() = %q, want user- prefix", username) t.Fatalf("deriveSSOUsername() = %q, want a 36-character UUID", username)
} }
if err := validateWritableUsername(username); err != nil { if err := validateWritableUsername(username); err != nil {
t.Fatalf("deriveSSOUsername() produced invalid username %q: %v", username, err) t.Fatalf("deriveSSOUsername() produced invalid username %q: %v", username, err)
@@ -17,6 +17,27 @@ import (
"github.com/usememos/memos/store" "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) { func TestListMemos(t *testing.T) {
ctx := context.Background() ctx := context.Background()
+14 -2
View File
@@ -27,12 +27,24 @@ func parseUsernameFromName(name string) (string, error) {
} }
func validateWritableUsername(username string) error { func validateWritableUsername(username string) error {
if !base.ResourceIDMatcher.MatchString(username) { if username == "" || isNumericUsername(username) || !base.UIDMatcher.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") return errors.Errorf("invalid username %q", username)
} }
return nil 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. // 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) { func ResolveUserByName(ctx context.Context, stores *store.Store, name string) (*store.User, error) {
username, err := parseUsernameFromName(name) username, err := parseUsernameFromName(name)
@@ -16,9 +16,8 @@ func TestValidateWritableUsername(t *testing.T) {
username: "alice", username: "alice",
}, },
{ {
name: "mixed case", name: "mixed case",
username: "Alice", username: "Alice",
wantError: true,
}, },
{ {
name: "hyphenated", name: "hyphenated",
@@ -30,17 +29,16 @@ func TestValidateWritableUsername(t *testing.T) {
}, },
{ {
name: "maximum length", name: "maximum length",
username: "a" + strings.Repeat("b", 62), username: "a" + strings.Repeat("b", 35),
}, },
{ {
name: "too long", name: "too long",
username: "a" + strings.Repeat("b", 63), username: "a" + strings.Repeat("b", 36),
wantError: true, wantError: true,
}, },
{ {
name: "digit first", name: "digit first",
username: "1alice", username: "1alice",
wantError: true,
}, },
{ {
name: "hyphen last", name: "hyphen last",
@@ -146,7 +146,7 @@ export type CreateAttachmentRequest = Message<"memos.api.v1.CreateAttachmentRequ
/** /**
* Optional. The attachment ID to use for this attachment. * Optional. The attachment ID to use for this attachment.
* If empty, a unique ID will be generated. * 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; * @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. * 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. * 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; * @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"> & { export type Memo = Message<"memos.api.v1.Memo"> & {
/** /**
* The resource name of the 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; * @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. * Optional. The memo ID to use for this memo.
* If empty, a unique ID will be generated. * 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; * @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 * Optional. The resource ID to use for this user. If set, it must equal
* user.username and follow the User resource ID format. * user.username, follow the UID format, and not consist entirely of digits.
* 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 user_id = 2; * @generated from field: string user_id = 2;
*/ */