feat(api): support username lookup in GetUser endpoint

- Update GetUser to accept both numeric IDs and username strings (users/{id} or users/{username})
- Implement CEL filter parsing for username-based lookups
- Update proto documentation to reflect dual lookup capability
- Simplify frontend user store to use GetUser instead of ListUsers filter
- Update ListUsers filter documentation to show current capabilities
pull/5038/merge
Steven 2 weeks ago
parent 4d4325eba5
commit 9121ddbad9

@ -20,7 +20,10 @@ service UserService {
option (google.api.http) = {get: "/api/v1/users"};
}
// GetUser gets a user by name.
// GetUser gets a user by ID or username.
// Supports both numeric IDs and username strings:
// - users/{id} (e.g., users/101)
// - users/{username} (e.g., users/steven)
rpc GetUser(GetUserRequest) returns (User) {
option (google.api.http) = {get: "/api/v1/{name=users/*}"};
option (google.api.method_signature) = "name";
@ -220,9 +223,9 @@ message ListUsersRequest {
string page_token = 2 [(google.api.field_behavior) = OPTIONAL];
// Optional. Filter to apply to the list results.
// Example: "state=ACTIVE" or "role=USER" or "email:@example.com"
// Supported operators: =, !=, <, <=, >, >=, :
// Supported fields: username, email, role, state, create_time, update_time
// Example: "username == 'steven'"
// Supported operators: ==
// Supported fields: username
string filter = 3 [(google.api.field_behavior) = OPTIONAL];
// Optional. If true, show deleted users in the response.
@ -243,7 +246,10 @@ message ListUsersResponse {
message GetUserRequest {
// Required. The resource name of the user.
// Format: users/{user}
// Supports both numeric IDs and username strings:
// - users/{id} (e.g., users/101)
// - users/{username} (e.g., users/steven)
// Format: users/{id_or_username}
string name = 1 [
(google.api.field_behavior) = REQUIRED,
(google.api.resource_reference) = {type: "memos.api.v1/User"}

@ -290,9 +290,9 @@ type ListUsersRequest struct {
// Provide this to retrieve the subsequent page.
PageToken string `protobuf:"bytes,2,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"`
// Optional. Filter to apply to the list results.
// Example: "state=ACTIVE" or "role=USER" or "email:@example.com"
// Supported operators: =, !=, <, <=, >, >=, :
// Supported fields: username, email, role, state, create_time, update_time
// Example: "username == 'steven'"
// Supported operators: ==
// Supported fields: username
Filter string `protobuf:"bytes,3,opt,name=filter,proto3" json:"filter,omitempty"`
// Optional. If true, show deleted users in the response.
ShowDeleted bool `protobuf:"varint,4,opt,name=show_deleted,json=showDeleted,proto3" json:"show_deleted,omitempty"`
@ -425,7 +425,11 @@ func (x *ListUsersResponse) GetTotalSize() int32 {
type GetUserRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Required. The resource name of the user.
// Format: users/{user}
// Supports both numeric IDs and username strings:
// - users/{id} (e.g., users/101)
// - users/{username} (e.g., users/steven)
//
// Format: users/{id_or_username}
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
// Optional. The fields to return in the response.
// If not specified, all fields are returned.

@ -49,7 +49,10 @@ const (
type UserServiceClient interface {
// ListUsers returns a list of users.
ListUsers(ctx context.Context, in *ListUsersRequest, opts ...grpc.CallOption) (*ListUsersResponse, error)
// GetUser gets a user by name.
// GetUser gets a user by ID or username.
// Supports both numeric IDs and username strings:
// - users/{id} (e.g., users/101)
// - users/{username} (e.g., users/steven)
GetUser(ctx context.Context, in *GetUserRequest, opts ...grpc.CallOption) (*User, error)
// CreateUser creates a new user.
CreateUser(ctx context.Context, in *CreateUserRequest, opts ...grpc.CallOption) (*User, error)
@ -303,7 +306,10 @@ func (c *userServiceClient) DeleteUserWebhook(ctx context.Context, in *DeleteUse
type UserServiceServer interface {
// ListUsers returns a list of users.
ListUsers(context.Context, *ListUsersRequest) (*ListUsersResponse, error)
// GetUser gets a user by name.
// GetUser gets a user by ID or username.
// Supports both numeric IDs and username strings:
// - users/{id} (e.g., users/101)
// - users/{username} (e.g., users/steven)
GetUser(context.Context, *GetUserRequest) (*User, error)
// CreateUser creates a new user.
CreateUser(context.Context, *CreateUserRequest) (*User, error)

@ -1217,9 +1217,9 @@ paths:
in: query
description: |-
Optional. Filter to apply to the list results.
Example: "state=ACTIVE" or "role=USER" or "email:@example.com"
Supported operators: =, !=, <, <=, >, >=, :
Supported fields: username, email, role, state, create_time, update_time
Example: "username == 'steven'"
Supported operators: ==
Supported fields: username
schema:
type: string
- name: showDeleted
@ -1289,7 +1289,11 @@ paths:
get:
tags:
- UserService
description: GetUser gets a user by name.
description: |-
GetUser gets a user by ID or username.
Supports both numeric IDs and username strings:
- users/{id} (e.g., users/101)
- users/{username} (e.g., users/steven)
operationId: UserService_GetUser
parameters:
- name: user

@ -73,6 +73,17 @@ func ExtractUserIDFromName(name string) (int32, error) {
return id, nil
}
// extractUserIdentifierFromName extracts the identifier (ID or username) from a user resource name.
// Supports: "users/101" or "users/steven"
// Returns the identifier string (e.g., "101" or "steven")
func extractUserIdentifierFromName(name string) string {
tokens, err := GetNameParentTokens(name, UserNamePrefix)
if err != nil || len(tokens) == 0 {
return ""
}
return tokens[0]
}
// ExtractMemoUIDFromName returns the memo UID from a resource name.
// e.g., "memos/uuid" -> "uuid".
func ExtractMemoUIDFromName(name string) (string, error) {

@ -14,6 +14,8 @@ import (
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/cel-go/cel"
"github.com/google/cel-go/common/ast"
"github.com/labstack/echo/v4"
"github.com/pkg/errors"
"golang.org/x/crypto/bcrypt"
@ -45,9 +47,13 @@ func (s *APIV1Service) ListUsers(ctx context.Context, request *v1pb.ListUsersReq
userFind := &store.FindUser{}
if request.Filter != "" {
if err := validateUserFilter(ctx, request.Filter); err != nil {
username, err := extractUsernameFromFilter(request.Filter)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid filter: %v", err)
}
if username != "" {
userFind.Username = &username
}
}
users, err := s.Store.ListUsers(ctx, userFind)
@ -68,13 +74,29 @@ func (s *APIV1Service) ListUsers(ctx context.Context, request *v1pb.ListUsersReq
}
func (s *APIV1Service) GetUser(ctx context.Context, request *v1pb.GetUserRequest) (*v1pb.User, error) {
userID, err := ExtractUserIDFromName(request.Name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %v", err)
// Extract identifier from "users/{id_or_username}"
identifier := extractUserIdentifierFromName(request.Name)
if identifier == "" {
return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %s", request.Name)
}
user, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
var user *store.User
var err error
// Try to parse as numeric ID first
if userID, parseErr := strconv.ParseInt(identifier, 10, 32); parseErr == nil {
// It's a numeric ID
userID32 := int32(userID)
user, err = s.Store.GetUser(ctx, &store.FindUser{
ID: &userID32,
})
} else {
// It's a username
user, err = s.Store.GetUser(ctx, &store.FindUser{
Username: &identifier,
})
}
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
}
@ -1358,10 +1380,95 @@ func extractWebhookIDFromName(name string) string {
return ""
}
// validateUserFilter validates the user filter string.
func validateUserFilter(_ context.Context, filterStr string) error {
if strings.TrimSpace(filterStr) != "" {
return errors.New("user filters are not supported")
// extractUsernameFromFilter extracts username from the filter string using CEL.
// Supported filter format: "username == 'steven'"
// Returns the username value and an error if the filter format is invalid.
func extractUsernameFromFilter(filterStr string) (string, error) {
filterStr = strings.TrimSpace(filterStr)
if filterStr == "" {
return "", nil
}
return nil
// Create CEL environment with username variable
env, err := cel.NewEnv(
cel.Variable("username", cel.StringType),
)
if err != nil {
return "", errors.Wrap(err, "failed to create CEL environment")
}
// Parse and check the filter expression
celAST, issues := env.Compile(filterStr)
if issues != nil && issues.Err() != nil {
return "", errors.Wrapf(issues.Err(), "invalid filter expression: %s", filterStr)
}
// Extract username from the AST
username, err := extractUsernameFromAST(celAST.NativeRep().Expr())
if err != nil {
return "", err
}
return username, nil
}
// extractUsernameFromAST extracts the username value from a CEL AST expression.
func extractUsernameFromAST(expr ast.Expr) (string, error) {
if expr == nil {
return "", errors.New("empty expression")
}
// Check if this is a call expression (for ==, !=, etc.)
if expr.Kind() != ast.CallKind {
return "", errors.New("filter must be a comparison expression (e.g., username == 'value')")
}
call := expr.AsCall()
// We only support == operator
if call.FunctionName() != "_==_" {
return "", errors.Errorf("unsupported operator: %s (only '==' is supported)", call.FunctionName())
}
// The call should have exactly 2 arguments
args := call.Args()
if len(args) != 2 {
return "", errors.New("invalid comparison expression")
}
// Try to extract username from either left or right side
if username, ok := extractUsernameFromComparison(args[0], args[1]); ok {
return username, nil
}
if username, ok := extractUsernameFromComparison(args[1], args[0]); ok {
return username, nil
}
return "", errors.New("filter must compare 'username' field with a string constant")
}
// extractUsernameFromComparison tries to extract username value if left is 'username' ident and right is a string constant.
func extractUsernameFromComparison(left, right ast.Expr) (string, bool) {
// Check if left side is 'username' identifier
if left.Kind() != ast.IdentKind {
return "", false
}
ident := left.AsIdent()
if ident != "username" {
return "", false
}
// Right side should be a constant string
if right.Kind() != ast.LiteralKind {
return "", false
}
literal := right.AsLiteral()
// literal is a ref.Val, we need to get the Go value
str, ok := literal.Value().(string)
if !ok || str == "" {
return "", false
}
return str, true
}

@ -83,12 +83,10 @@ const userStore = (() => {
return userMap[name];
}
}
// Use search instead of the deprecated getUserByUsername
const { users } = await userServiceClient.listUsers({
filter: `username == "${username}"`,
pageSize: 10,
// Use GetUser with username - supports both "users/{id}" and "users/{username}"
const user = await userServiceClient.getUser({
name: `users/${username}`,
});
const user = users.find((u) => u.username === username);
if (!user) {
throw new Error(`User with username ${username} not found`);
}

@ -109,9 +109,9 @@ export interface ListUsersRequest {
pageToken: string;
/**
* Optional. Filter to apply to the list results.
* Example: "state=ACTIVE" or "role=USER" or "email:@example.com"
* Supported operators: =, !=, <, <=, >, >=, :
* Supported fields: username, email, role, state, create_time, update_time
* Example: "username == 'steven'"
* Supported operators: ==
* Supported fields: username
*/
filter: string;
/** Optional. If true, show deleted users in the response. */
@ -133,7 +133,10 @@ export interface ListUsersResponse {
export interface GetUserRequest {
/**
* Required. The resource name of the user.
* Format: users/{user}
* Supports both numeric IDs and username strings:
* - users/{id} (e.g., users/101)
* - users/{username} (e.g., users/steven)
* Format: users/{id_or_username}
*/
name: string;
/**
@ -3221,7 +3224,12 @@ export const UserServiceDefinition = {
},
},
},
/** GetUser gets a user by name. */
/**
* GetUser gets a user by ID or username.
* Supports both numeric IDs and username strings:
* - users/{id} (e.g., users/101)
* - users/{username} (e.g., users/steven)
*/
getUser: {
name: "GetUser",
requestType: GetUserRequest,

Loading…
Cancel
Save