mirror of
https://github.com/usememos/memos.git
synced 2025-12-17 14:19:17 +08:00
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
This commit is contained in:
parent
4d4325eba5
commit
9121ddbad9
8 changed files with 181 additions and 37 deletions
|
|
@ -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…
Add table
Reference in a new issue