diff --git a/all/internal/server/authorization.go b/all/internal/server/authorization.go index 3e777b7..7f4eb7d 100644 --- a/all/internal/server/authorization.go +++ b/all/internal/server/authorization.go @@ -2,6 +2,7 @@ package server import ( "context" + "encoding/json" "errors" "fmt" "net/http" @@ -9,11 +10,10 @@ import ( "strings" "time" + "git.apinb.com/bsm-sdk/core/errcode" "github.com/golang-jwt/jwt/v5" "google.golang.org/grpc" - "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" - "google.golang.org/grpc/status" ) type authorization struct { @@ -25,10 +25,10 @@ type authorization struct { func newAuthorization(key string, expireSeconds int64, anonymous []string) (*authorization, error) { key = strings.TrimSpace(key) if key == "" { - return nil, errors.New("authorization key must not be empty") + return nil, errcode.ErrTokenSecretKeyNotFound } if expireSeconds <= 0 { - return nil, errors.New("authorization expiration must be greater than zero") + return nil, errcode.ErrTokenAuthExpire } allowed := make(map[string]struct{}, len(anonymous)) for _, item := range anonymous { @@ -43,10 +43,10 @@ func (a *authorization) unaryInterceptor(ctx context.Context, req any, info *grp if !a.isAnonymous(info.FullMethod) { values := metadata.ValueFromIncomingContext(ctx, "authorization") if len(values) == 0 { - return nil, status.Error(codes.Unauthenticated, "authorization is required") + return nil, errcode.ErrHeaderAuthorization } if err := a.validate(values[0]); err != nil { - return nil, status.Error(codes.Unauthenticated, err.Error()) + return nil, err } } return handler(ctx, req) @@ -68,7 +68,7 @@ func (a *authorization) httpMiddleware(next http.Handler) http.Handler { func (a *authorization) validate(raw string) error { raw = strings.TrimSpace(raw) if raw == "" { - return errors.New("authorization is required") + return errcode.ErrHeaderAuthorization } claims := &jwt.RegisteredClaims{} tokenValue, err := jwt.ParseWithClaims(raw, claims, func(tokenValue *jwt.Token) (any, error) { @@ -78,14 +78,17 @@ func (a *authorization) validate(raw string) error { return a.key, nil }, jwt.WithExpirationRequired(), jwt.WithIssuedAt(), jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()})) if err != nil || !tokenValue.Valid { - return errors.New("authorization is invalid or expired") + if errors.Is(err, jwt.ErrTokenExpired) { + return errcode.ErrTokenAuthExpire + } + return errcode.ErrTokenAuthParseFail } if claims.IssuedAt == nil { - return errors.New("authorization issued-at is required") + return errcode.ErrTokenDataInvalid } now := time.Now() if claims.IssuedAt.Time.After(now) || now.Sub(claims.IssuedAt.Time) > a.expire { - return errors.New("authorization is expired") + return errcode.ErrTokenAuthExpire } return nil } @@ -119,5 +122,5 @@ func normalizePath(value string) string { func writeHTTPAuthorizationError(w http.ResponseWriter, err error) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusOK) - _, _ = fmt.Fprintf(w, `{"code":%d,"message":%q}`, codes.Unauthenticated, err.Error()) + _ = json.NewEncoder(w).Encode(newErrorResponse(err)) } diff --git a/all/internal/server/authorization_test.go b/all/internal/server/authorization_test.go index d9127ac..7271c0a 100644 --- a/all/internal/server/authorization_test.go +++ b/all/internal/server/authorization_test.go @@ -2,15 +2,16 @@ package server import ( "context" + "fmt" "net/http" "net/http/httptest" "strings" "testing" "time" + "git.apinb.com/bsm-sdk/core/errcode" "github.com/golang-jwt/jwt/v5" "google.golang.org/grpc" - "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" ) @@ -30,13 +31,14 @@ func TestHTTPAuthorization(t *testing.T) { path string token string want int + code int32 }{ {name: "grpc gateway anonymous", path: "/passport.Login/Pwd", want: http.StatusNoContent}, {name: "dynamic rpc anonymous", path: "/rpc/passport/Login/Pwd", want: http.StatusNoContent}, {name: "rest anonymous", path: "/rest/fts/ping", want: http.StatusNoContent}, - {name: "missing token", path: "/passport.Account/Get", want: http.StatusOK}, + {name: "missing token", path: "/passport.Account/Get", want: http.StatusOK, code: int32(status.Code(errcode.ErrHeaderAuthorization))}, {name: "valid raw token", path: "/passport.Account/Get", token: signedToken(t, time.Now(), time.Now().Add(time.Hour)), want: http.StatusNoContent}, - {name: "bearer rejected", path: "/passport.Account/Get", token: "Bearer " + signedToken(t, time.Now(), time.Now().Add(time.Hour)), want: http.StatusOK}, + {name: "bearer rejected", path: "/passport.Account/Get", token: "Bearer " + signedToken(t, time.Now(), time.Now().Add(time.Hour)), want: http.StatusOK, code: int32(status.Code(errcode.ErrTokenAuthParseFail))}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -49,7 +51,7 @@ func TestHTTPAuthorization(t *testing.T) { if response.Code != test.want { t.Fatalf("status=%d body=%s", response.Code, response.Body.String()) } - if test.want == http.StatusOK && !strings.Contains(response.Body.String(), `"code":16`) { + if test.want == http.StatusOK && !strings.Contains(response.Body.String(), `"code":`+fmt.Sprint(test.code)) { t.Fatalf("unexpected authorization error: %s", response.Body.String()) } }) @@ -76,7 +78,7 @@ func TestGRPCAuthorization(t *testing.T) { if _, err := auth.unaryInterceptor(context.Background(), nil, &grpc.UnaryServerInfo{FullMethod: "/passport.Login/Pwd"}, handler); err != nil { t.Fatalf("anonymous method failed: %v", err) } - if _, err := auth.unaryInterceptor(context.Background(), nil, &grpc.UnaryServerInfo{FullMethod: "/passport.Account/Get"}, handler); status.Code(err) != codes.Unauthenticated { + if _, err := auth.unaryInterceptor(context.Background(), nil, &grpc.UnaryServerInfo{FullMethod: "/passport.Account/Get"}, handler); status.Code(err) != status.Code(errcode.ErrHeaderAuthorization) { t.Fatalf("expected unauthenticated, got %v", err) } ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("authorization", signedToken(t, time.Now(), time.Now().Add(time.Hour)))) diff --git a/all/internal/server/dynamic.go b/all/internal/server/dynamic.go index af41783..e3f28a4 100644 --- a/all/internal/server/dynamic.go +++ b/all/internal/server/dynamic.go @@ -10,6 +10,7 @@ import ( "strings" "sync" + "git.apinb.com/bsm-sdk/core/errcode" "github.com/gin-gonic/gin" "google.golang.org/grpc" "google.golang.org/grpc/codes" @@ -35,10 +36,9 @@ type dynamicGateway struct { } type dynamicRPCResponse struct { - Code int32 `json:"code"` - Message string `json:"message"` - Data json.RawMessage `json:"data,omitempty"` - Details []json.RawMessage `json:"details,omitempty"` + Code int32 `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data,omitempty"` } func newDynamicGateway(grpcAddr string) (*dynamicGateway, error) { @@ -67,7 +67,7 @@ func (g *dynamicGateway) handle(c *gin.Context) { serviceShortName := strings.TrimSpace(c.Param("service")) methodName := strings.TrimSpace(c.Param("method")) if moduleName == "" || serviceShortName == "" || methodName == "" { - writeDynamicError(c, status.Error(codes.InvalidArgument, "path must be /rpc/{module}/{service}/{method}")) + writeDynamicError(c, errcode.String(errcode.ErrInvalidArgument, "path must be /rpc/{module}/{service}/{method}")) return } serviceName := moduleName + "." + serviceShortName @@ -77,23 +77,23 @@ func (g *dynamicGateway) handle(c *gin.Context) { return } if descriptor.IsStreamingClient() || descriptor.IsStreamingServer() { - writeDynamicError(c, status.Error(codes.Unimplemented, "streaming RPC methods are not supported")) + writeDynamicError(c, errcode.String(errcode.ErrUnimplemented, "streaming RPC methods are not supported")) return } body, err := io.ReadAll(io.LimitReader(c.Request.Body, maxDynamicRPCBody+1)) if err != nil { - writeDynamicError(c, status.Error(codes.InvalidArgument, "read request body: "+err.Error())) + writeDynamicError(c, errcode.String(errcode.ErrInvalidArgument, "read request body: "+err.Error())) return } if len(body) > maxDynamicRPCBody { - writeDynamicError(c, status.Error(codes.ResourceExhausted, "request body exceeds 4 MiB")) + writeDynamicError(c, errcode.String(errcode.ErrResourceExhausted, "request body exceeds 4 MiB")) return } request := dynamicpb.NewMessage(descriptor.Input()) if err := (protojson.UnmarshalOptions{DiscardUnknown: false}).Unmarshal(body, request); err != nil { - writeDynamicError(c, status.Error(codes.InvalidArgument, "invalid protobuf JSON: "+err.Error())) + writeDynamicError(c, errcode.String(errcode.ErrJsonUnmarshal, err.Error())) return } response := dynamicpb.NewMessage(descriptor.Output()) @@ -106,7 +106,7 @@ func (g *dynamicGateway) handle(c *gin.Context) { data, err := (protojson.MarshalOptions{UseProtoNames: false}).Marshal(response) if err != nil { - writeDynamicError(c, status.Error(codes.Internal, "marshal protobuf response: "+err.Error())) + writeDynamicError(c, errcode.String(errcode.ErrJsonMarshal, err.Error())) return } c.JSON(http.StatusOK, dynamicRPCResponse{Code: int32(codes.OK), Message: codes.OK.String(), Data: data}) @@ -123,51 +123,51 @@ func (g *dynamicGateway) resolveMethod(ctx context.Context, serviceName, methodN stream, err := reflectionv1.NewServerReflectionClient(g.conn).ServerReflectionInfo(ctx) if err != nil { - return nil, status.Error(codes.Unavailable, "open gRPC reflection stream: "+err.Error()) + return nil, errcode.String(errcode.ErrUnavailable, "open gRPC reflection stream: "+err.Error()) } if err := stream.Send(&reflectionv1.ServerReflectionRequest{ MessageRequest: &reflectionv1.ServerReflectionRequest_FileContainingSymbol{FileContainingSymbol: serviceName}, }); err != nil { - return nil, status.Error(codes.Unavailable, "query gRPC reflection: "+err.Error()) + return nil, errcode.String(errcode.ErrUnavailable, "query gRPC reflection: "+err.Error()) } reflectionResponse, err := stream.Recv() if err != nil { - return nil, status.Error(codes.Unavailable, "read gRPC reflection response: "+err.Error()) + return nil, errcode.String(errcode.ErrUnavailable, "read gRPC reflection response: "+err.Error()) } fileResponse := reflectionResponse.GetFileDescriptorResponse() if fileResponse == nil { if reflectionErr := reflectionResponse.GetErrorResponse(); reflectionErr != nil { - return nil, status.Error(codes.Code(reflectionErr.ErrorCode), reflectionErr.ErrorMessage) + return nil, sdkError(status.Error(codes.Code(reflectionErr.ErrorCode), reflectionErr.ErrorMessage)) } - return nil, status.Error(codes.NotFound, "service descriptor not found") + return nil, errcode.String(errcode.ErrRecordNotFound, "service descriptor not found") } set := &descriptorpb.FileDescriptorSet{} for _, encoded := range fileResponse.FileDescriptorProto { file := &descriptorpb.FileDescriptorProto{} if err := proto.Unmarshal(encoded, file); err != nil { - return nil, status.Error(codes.Internal, "decode reflected descriptor: "+err.Error()) + return nil, errcode.String(errcode.ErrInternal, "decode reflected descriptor: "+err.Error()) } set.File = append(set.File, file) } files, err := protodesc.NewFiles(set) if err != nil { - return nil, status.Error(codes.Internal, "build reflected descriptors: "+err.Error()) + return nil, errcode.String(errcode.ErrInternal, "build reflected descriptors: "+err.Error()) } descriptor, err := files.FindDescriptorByName(protoreflect.FullName(serviceName)) if err != nil { if err == protoregistry.NotFound { - return nil, status.Error(codes.NotFound, "service not found") + return nil, errcode.String(errcode.ErrRecordNotFound, "service not found") } - return nil, status.Error(codes.Internal, "resolve service descriptor: "+err.Error()) + return nil, errcode.String(errcode.ErrInternal, "resolve service descriptor: "+err.Error()) } service, ok := descriptor.(protoreflect.ServiceDescriptor) if !ok { - return nil, status.Error(codes.NotFound, "symbol is not a gRPC service") + return nil, errcode.String(errcode.ErrRecordNotFound, "symbol is not a gRPC service") } method = service.Methods().ByName(protoreflect.Name(methodName)) if method == nil { - return nil, status.Error(codes.NotFound, "method not found") + return nil, errcode.String(errcode.ErrRecordNotFound, "method not found") } g.mu.Lock() g.cache[cacheKey] = method @@ -190,20 +190,5 @@ func outgoingMetadata(request *http.Request) context.Context { } func writeDynamicError(c *gin.Context, err error) { - grpcStatus := status.Convert(err) - details := make([]json.RawMessage, 0, len(grpcStatus.Details())) - for _, detail := range grpcStatus.Details() { - message, ok := detail.(proto.Message) - if !ok { - continue - } - if encoded, marshalErr := protojson.Marshal(message); marshalErr == nil { - details = append(details, encoded) - } - } - c.JSON(http.StatusOK, dynamicRPCResponse{ - Code: int32(grpcStatus.Code()), - Message: grpcStatus.Message(), - Details: details, - }) + c.JSON(http.StatusOK, newErrorResponse(sdkError(err))) } diff --git a/all/internal/server/response.go b/all/internal/server/response.go new file mode 100644 index 0000000..aa1e7b5 --- /dev/null +++ b/all/internal/server/response.go @@ -0,0 +1,78 @@ +package server + +import ( + "time" + + "git.apinb.com/bsm-sdk/core/errcode" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type errorResponse struct { + Code int32 `json:"code"` + Message string `json:"message"` + Details any `json:"details"` + Timeseq int64 `json:"timeseq"` +} + +func sdkError(err error) error { + code := status.Code(err) + // SDK business errors already use custom codes and must pass through. + if code > codes.Unauthenticated { + return err + } + message := status.Convert(err).Message() + var target error + switch code { + case codes.Canceled: + target = errcode.ErrCanceled + case codes.InvalidArgument: + target = errcode.ErrInvalidArgument + case codes.DeadlineExceeded: + target = errcode.ErrDeadlineExceeded + case codes.NotFound: + target = errcode.ErrRecordNotFound + case codes.AlreadyExists: + target = errcode.ErrAlreadyExists + case codes.PermissionDenied: + target = errcode.ErrPermissionDenied + case codes.ResourceExhausted: + target = errcode.ErrResourceExhausted + case codes.FailedPrecondition: + target = errcode.ErrFailedPrecondition + case codes.Aborted: + target = errcode.ErrAborted + case codes.OutOfRange: + target = errcode.ErrOutOfRange + case codes.Unimplemented: + target = errcode.ErrUnimplemented + case codes.Unavailable: + target = errcode.ErrUnavailable + case codes.DataLoss: + target = errcode.ErrDataLoss + case codes.Unauthenticated: + target = errcode.ErrUnauthenticated + case codes.Internal: + target = errcode.ErrInternal + default: + target = errcode.ErrUnknown + } + if message == "" || message == status.Convert(target).Message() { + return target + } + return errcode.String(target, message) +} + +func newErrorResponse(err error) errorResponse { + response := errorResponse{ + Code: 500, + Message: err.Error(), + Details: "", + Timeseq: time.Now().UnixMilli(), + } + if grpcStatus, ok := status.FromError(err); ok { + response.Code = int32(grpcStatus.Code()) + response.Message = grpcStatus.Message() + } + return response +}