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"};
|
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) {
|
rpc GetUser(GetUserRequest) returns (User) {
|
||||||
option (google.api.http) = {get: "/api/v1/{name=users/*}"};
|
option (google.api.http) = {get: "/api/v1/{name=users/*}"};
|
||||||
option (google.api.method_signature) = "name";
|
option (google.api.method_signature) = "name";
|
||||||
|
|
@ -220,9 +223,9 @@ message ListUsersRequest {
|
||||||
string page_token = 2 [(google.api.field_behavior) = OPTIONAL];
|
string page_token = 2 [(google.api.field_behavior) = OPTIONAL];
|
||||||
|
|
||||||
// Optional. Filter to apply to the list results.
|
// Optional. Filter to apply to the list results.
|
||||||
// Example: "state=ACTIVE" or "role=USER" or "email:@example.com"
|
// Example: "username == 'steven'"
|
||||||
// Supported operators: =, !=, <, <=, >, >=, :
|
// Supported operators: ==
|
||||||
// Supported fields: username, email, role, state, create_time, update_time
|
// Supported fields: username
|
||||||
string filter = 3 [(google.api.field_behavior) = OPTIONAL];
|
string filter = 3 [(google.api.field_behavior) = OPTIONAL];
|
||||||
|
|
||||||
// Optional. If true, show deleted users in the response.
|
// Optional. If true, show deleted users in the response.
|
||||||
|
|
@ -243,7 +246,10 @@ message ListUsersResponse {
|
||||||
|
|
||||||
message GetUserRequest {
|
message GetUserRequest {
|
||||||
// Required. The resource name of the user.
|
// 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 [
|
string name = 1 [
|
||||||
(google.api.field_behavior) = REQUIRED,
|
(google.api.field_behavior) = REQUIRED,
|
||||||
(google.api.resource_reference) = {type: "memos.api.v1/User"}
|
(google.api.resource_reference) = {type: "memos.api.v1/User"}
|
||||||
|
|
|
||||||
|
|
@ -290,9 +290,9 @@ type ListUsersRequest struct {
|
||||||
// Provide this to retrieve the subsequent page.
|
// Provide this to retrieve the subsequent page.
|
||||||
PageToken string `protobuf:"bytes,2,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"`
|
PageToken string `protobuf:"bytes,2,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"`
|
||||||
// Optional. Filter to apply to the list results.
|
// Optional. Filter to apply to the list results.
|
||||||
// Example: "state=ACTIVE" or "role=USER" or "email:@example.com"
|
// Example: "username == 'steven'"
|
||||||
// Supported operators: =, !=, <, <=, >, >=, :
|
// Supported operators: ==
|
||||||
// Supported fields: username, email, role, state, create_time, update_time
|
// Supported fields: username
|
||||||
Filter string `protobuf:"bytes,3,opt,name=filter,proto3" json:"filter,omitempty"`
|
Filter string `protobuf:"bytes,3,opt,name=filter,proto3" json:"filter,omitempty"`
|
||||||
// Optional. If true, show deleted users in the response.
|
// 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"`
|
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 {
|
type GetUserRequest struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
// Required. The resource name of the user.
|
// 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"`
|
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
|
||||||
// Optional. The fields to return in the response.
|
// Optional. The fields to return in the response.
|
||||||
// If not specified, all fields are returned.
|
// If not specified, all fields are returned.
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,10 @@ const (
|
||||||
type UserServiceClient interface {
|
type UserServiceClient interface {
|
||||||
// ListUsers returns a list of users.
|
// ListUsers returns a list of users.
|
||||||
ListUsers(ctx context.Context, in *ListUsersRequest, opts ...grpc.CallOption) (*ListUsersResponse, error)
|
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)
|
GetUser(ctx context.Context, in *GetUserRequest, opts ...grpc.CallOption) (*User, error)
|
||||||
// CreateUser creates a new user.
|
// CreateUser creates a new user.
|
||||||
CreateUser(ctx context.Context, in *CreateUserRequest, opts ...grpc.CallOption) (*User, error)
|
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 {
|
type UserServiceServer interface {
|
||||||
// ListUsers returns a list of users.
|
// ListUsers returns a list of users.
|
||||||
ListUsers(context.Context, *ListUsersRequest) (*ListUsersResponse, error)
|
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)
|
GetUser(context.Context, *GetUserRequest) (*User, error)
|
||||||
// CreateUser creates a new user.
|
// CreateUser creates a new user.
|
||||||
CreateUser(context.Context, *CreateUserRequest) (*User, error)
|
CreateUser(context.Context, *CreateUserRequest) (*User, error)
|
||||||
|
|
|
||||||
|
|
@ -1217,9 +1217,9 @@ paths:
|
||||||
in: query
|
in: query
|
||||||
description: |-
|
description: |-
|
||||||
Optional. Filter to apply to the list results.
|
Optional. Filter to apply to the list results.
|
||||||
Example: "state=ACTIVE" or "role=USER" or "email:@example.com"
|
Example: "username == 'steven'"
|
||||||
Supported operators: =, !=, <, <=, >, >=, :
|
Supported operators: ==
|
||||||
Supported fields: username, email, role, state, create_time, update_time
|
Supported fields: username
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
- name: showDeleted
|
- name: showDeleted
|
||||||
|
|
@ -1289,7 +1289,11 @@ paths:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
- UserService
|
- 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
|
operationId: UserService_GetUser
|
||||||
parameters:
|
parameters:
|
||||||
- name: user
|
- name: user
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,17 @@ func ExtractUserIDFromName(name string) (int32, error) {
|
||||||
return id, nil
|
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.
|
// ExtractMemoUIDFromName returns the memo UID from a resource name.
|
||||||
// e.g., "memos/uuid" -> "uuid".
|
// e.g., "memos/uuid" -> "uuid".
|
||||||
func ExtractMemoUIDFromName(name string) (string, error) {
|
func ExtractMemoUIDFromName(name string) (string, error) {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"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/labstack/echo/v4"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
@ -45,9 +47,13 @@ func (s *APIV1Service) ListUsers(ctx context.Context, request *v1pb.ListUsersReq
|
||||||
userFind := &store.FindUser{}
|
userFind := &store.FindUser{}
|
||||||
|
|
||||||
if request.Filter != "" {
|
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)
|
return nil, status.Errorf(codes.InvalidArgument, "invalid filter: %v", err)
|
||||||
}
|
}
|
||||||
|
if username != "" {
|
||||||
|
userFind.Username = &username
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
users, err := s.Store.ListUsers(ctx, userFind)
|
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) {
|
func (s *APIV1Service) GetUser(ctx context.Context, request *v1pb.GetUserRequest) (*v1pb.User, error) {
|
||||||
userID, err := ExtractUserIDFromName(request.Name)
|
// Extract identifier from "users/{id_or_username}"
|
||||||
if err != nil {
|
identifier := extractUserIdentifierFromName(request.Name)
|
||||||
return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %v", err)
|
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 {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
|
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -1358,10 +1380,95 @@ func extractWebhookIDFromName(name string) string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateUserFilter validates the user filter string.
|
// extractUsernameFromFilter extracts username from the filter string using CEL.
|
||||||
func validateUserFilter(_ context.Context, filterStr string) error {
|
// Supported filter format: "username == 'steven'"
|
||||||
if strings.TrimSpace(filterStr) != "" {
|
// Returns the username value and an error if the filter format is invalid.
|
||||||
return errors.New("user filters are not supported")
|
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];
|
return userMap[name];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Use search instead of the deprecated getUserByUsername
|
// Use GetUser with username - supports both "users/{id}" and "users/{username}"
|
||||||
const { users } = await userServiceClient.listUsers({
|
const user = await userServiceClient.getUser({
|
||||||
filter: `username == "${username}"`,
|
name: `users/${username}`,
|
||||||
pageSize: 10,
|
|
||||||
});
|
});
|
||||||
const user = users.find((u) => u.username === username);
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error(`User with username ${username} not found`);
|
throw new Error(`User with username ${username} not found`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -109,9 +109,9 @@ export interface ListUsersRequest {
|
||||||
pageToken: string;
|
pageToken: string;
|
||||||
/**
|
/**
|
||||||
* Optional. Filter to apply to the list results.
|
* Optional. Filter to apply to the list results.
|
||||||
* Example: "state=ACTIVE" or "role=USER" or "email:@example.com"
|
* Example: "username == 'steven'"
|
||||||
* Supported operators: =, !=, <, <=, >, >=, :
|
* Supported operators: ==
|
||||||
* Supported fields: username, email, role, state, create_time, update_time
|
* Supported fields: username
|
||||||
*/
|
*/
|
||||||
filter: string;
|
filter: string;
|
||||||
/** Optional. If true, show deleted users in the response. */
|
/** Optional. If true, show deleted users in the response. */
|
||||||
|
|
@ -133,7 +133,10 @@ export interface ListUsersResponse {
|
||||||
export interface GetUserRequest {
|
export interface GetUserRequest {
|
||||||
/**
|
/**
|
||||||
* Required. The resource name of the user.
|
* 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;
|
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: {
|
getUser: {
|
||||||
name: "GetUser",
|
name: "GetUser",
|
||||||
requestType: GetUserRequest,
|
requestType: GetUserRequest,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue