diff --git a/cmd/webserver.go b/cmd/webserver.go index a14ce483..07bad6a7 100644 --- a/cmd/webserver.go +++ b/cmd/webserver.go @@ -19,6 +19,7 @@ import ( "github.com/mayswind/ezbookkeeping/pkg/cron" "github.com/mayswind/ezbookkeeping/pkg/errs" "github.com/mayswind/ezbookkeeping/pkg/log" + "github.com/mayswind/ezbookkeeping/pkg/mcp" "github.com/mayswind/ezbookkeeping/pkg/middlewares" "github.com/mayswind/ezbookkeeping/pkg/requestid" "github.com/mayswind/ezbookkeeping/pkg/settings" @@ -64,6 +65,13 @@ func startWebServer(c *core.CliContext) error { return err } + err = mcp.InitializeMCPHandlers(config) + + if err != nil { + log.BootErrorf(c, "[webserver.startWebServer] initializes mcp handlers failed, because %s", err.Error()) + return err + } + err = cron.InitializeCronJobSchedulerContainer(c, config, true) if err != nil { diff --git a/pkg/api/model_context_protocols.go b/pkg/api/model_context_protocols.go index 4925026e..ec6091e5 100644 --- a/pkg/api/model_context_protocols.go +++ b/pkg/api/model_context_protocols.go @@ -102,8 +102,25 @@ func (a *ModelContextProtocolAPI) ReadResourceHandler(c *core.WebContext, jsonRP // ListToolsHandler returns the list of tools for model context protocol func (a *ModelContextProtocolAPI) ListToolsHandler(c *core.WebContext, jsonRPCRequest *core.JSONRPCRequest) (any, *errs.Error) { + mcpVersion := a.getMCPVersion(c) + toolsInfo := mcp.Container.GetMCPTools() + finalToolsInfos := make([]*mcp.MCPTool, len(toolsInfo)) + + for i := 0; i < len(toolsInfo); i++ { + finalToolsInfos[i] = &mcp.MCPTool{ + Name: toolsInfo[i].Name, + InputSchema: toolsInfo[i].InputSchema, + Title: toolsInfo[i].Title, + Description: toolsInfo[i].Description, + } + + if mcpVersion >= string(mcp.ToolResultStructuredContentMinVersion) { + finalToolsInfos[i].OutputSchema = toolsInfo[i].OutputSchema + } + } + listToolsResp := mcp.MCPListToolsResponse{ - Tools: mcp.AllMCPToolInfos, + Tools: finalToolsInfos, } return listToolsResp, nil @@ -121,7 +138,7 @@ func (a *ModelContextProtocolAPI) CallToolHandler(c *core.WebContext, jsonRPCReq return nil, errs.ErrIncompleteOrIncorrectSubmission } - result, err := mcp.MCPToolHandle(c, &callToolReq, a.CurrentConfig(), a) + result, err := mcp.Container.HandleTool(c, &callToolReq, a.CurrentConfig(), a) if err != nil { return nil, err @@ -159,3 +176,8 @@ func (a *ModelContextProtocolAPI) GetAccountService() *services.AccountService { func (a *ModelContextProtocolAPI) GetUserService() *services.UserService { return a.users } + +// getMCPVersion returns the MCP protocol version from the request header +func (a *ModelContextProtocolAPI) getMCPVersion(c *core.WebContext) string { + return c.GetHeader(mcp.MCPProtocolVersionHeaderName) +} diff --git a/pkg/mcp/all_handlers.go b/pkg/mcp/all_handlers.go deleted file mode 100644 index 7ce653cc..00000000 --- a/pkg/mcp/all_handlers.go +++ /dev/null @@ -1,12 +0,0 @@ -package mcp - -var mcpTextContentTools = map[string]MCPToolHandler[MCPTextContent]{ - "query_latest_exchange_rates": MCPQueryLatestExchangeRatesRequestToolHandler, -} - -var mcpImageContentTools = map[string]MCPToolHandler[MCPImageContent]{} -var mcpAudioContentTools = map[string]MCPToolHandler[MCPAudioContent]{} -var mcpResourceLinkTools = map[string]MCPToolHandler[MCPResourceLink]{} -var mcpEmbeddedResourceTools = map[string]MCPToolHandler[MCPEmbeddedResource]{} - -var AllMCPToolInfos = GetAllMCPToolInfos() diff --git a/pkg/mcp/handler.go b/pkg/mcp/handler.go index c849d9d2..06510000 100644 --- a/pkg/mcp/handler.go +++ b/pkg/mcp/handler.go @@ -3,7 +3,6 @@ package mcp import ( "reflect" - "github.com/invopop/jsonschema" "github.com/mayswind/ezbookkeeping/pkg/core" "github.com/mayswind/ezbookkeeping/pkg/errs" "github.com/mayswind/ezbookkeeping/pkg/services" @@ -21,6 +20,9 @@ type MCPAvailableServices interface { // MCPToolHandler defines the MCP tool handler type MCPToolHandler[T MCPTextContent | MCPImageContent | MCPAudioContent | MCPResourceLink | MCPEmbeddedResource] interface { + // Name returns the name of the MCP tool + Name() string + // Description returns the description of the MCP tool Description() string @@ -31,157 +33,5 @@ type MCPToolHandler[T MCPTextContent | MCPImageContent | MCPAudioContent | MCPRe OutputType() reflect.Type // Handle processes the MCP call tool request and returns the response - Handle(*core.WebContext, *MCPCallToolRequest, *settings.Config, MCPAvailableServices) ([]*T, *errs.Error) -} - -// GetAllMCPToolInfos returns all available MCP tool information -func GetAllMCPToolInfos() []*MCPTool { - toolInfos := make([]*MCPTool, 0) - - for name, handler := range mcpTextContentTools { - toolInfos = append(toolInfos, getMCPToolInfo(name, handler)) - } - - for name, handler := range mcpImageContentTools { - toolInfos = append(toolInfos, getMCPToolInfo(name, handler)) - } - - for name, handler := range mcpAudioContentTools { - toolInfos = append(toolInfos, getMCPToolInfo(name, handler)) - } - - for name, handler := range mcpResourceLinkTools { - toolInfos = append(toolInfos, getMCPToolInfo(name, handler)) - } - - for name, handler := range mcpEmbeddedResourceTools { - toolInfos = append(toolInfos, getMCPToolInfo(name, handler)) - } - - return toolInfos -} - -// MCPToolHandle handles the MCP tool request based on the tool name -func MCPToolHandle(c *core.WebContext, callToolReq *MCPCallToolRequest, currentConfig *settings.Config, services MCPAvailableServices) (any, *errs.Error) { - if handler, exists := mcpTextContentTools[callToolReq.Name]; exists { - return mcpTextContentToolHandle(c, handler, currentConfig, services, callToolReq) - } - - if handler, exists := mcpImageContentTools[callToolReq.Name]; exists { - return mcpImageContentToolHandle(c, handler, currentConfig, services, callToolReq) - } - - if handler, exists := mcpAudioContentTools[callToolReq.Name]; exists { - return mcpAudioContentToolHandle(c, handler, currentConfig, services, callToolReq) - } - - if handler, exists := mcpResourceLinkTools[callToolReq.Name]; exists { - return mcpResourceLinkToolHandle(c, handler, currentConfig, services, callToolReq) - } - - if handler, exists := mcpEmbeddedResourceTools[callToolReq.Name]; exists { - return mcpEmbeddedResourceToolHandle(c, handler, currentConfig, services, callToolReq) - } - - return nil, errs.ErrApiNotFound -} - -func mcpTextContentToolHandle(c *core.WebContext, handler MCPToolHandler[MCPTextContent], currentConfig *settings.Config, services MCPAvailableServices, callToolReq *MCPCallToolRequest) (any, *errs.Error) { - result, err := handler.Handle(c, callToolReq, currentConfig, services) - - if err != nil { - return nil, errs.Or(err, errs.ErrOperationFailed) - } - - callToolResp := MCPCallToolResponse[MCPTextContent]{ - Content: result, - IsError: false, - } - - return callToolResp, nil -} - -func mcpImageContentToolHandle(c *core.WebContext, handler MCPToolHandler[MCPImageContent], currentConfig *settings.Config, services MCPAvailableServices, callToolReq *MCPCallToolRequest) (any, *errs.Error) { - result, err := handler.Handle(c, callToolReq, currentConfig, services) - - if err != nil { - return nil, errs.Or(err, errs.ErrOperationFailed) - } - - callToolResp := MCPCallToolResponse[MCPImageContent]{ - Content: result, - IsError: false, - } - - return callToolResp, nil -} - -func mcpAudioContentToolHandle(c *core.WebContext, handler MCPToolHandler[MCPAudioContent], currentConfig *settings.Config, services MCPAvailableServices, callToolReq *MCPCallToolRequest) (any, *errs.Error) { - result, err := handler.Handle(c, callToolReq, currentConfig, services) - - if err != nil { - return nil, errs.Or(err, errs.ErrOperationFailed) - } - - callToolResp := MCPCallToolResponse[MCPAudioContent]{ - Content: result, - IsError: false, - } - - return callToolResp, nil -} - -func mcpResourceLinkToolHandle(c *core.WebContext, handler MCPToolHandler[MCPResourceLink], currentConfig *settings.Config, services MCPAvailableServices, callToolReq *MCPCallToolRequest) (any, *errs.Error) { - result, err := handler.Handle(c, callToolReq, currentConfig, services) - - if err != nil { - return nil, errs.Or(err, errs.ErrOperationFailed) - } - - callToolResp := MCPCallToolResponse[MCPResourceLink]{ - Content: result, - IsError: false, - } - - return callToolResp, nil -} - -func mcpEmbeddedResourceToolHandle(c *core.WebContext, handler MCPToolHandler[MCPEmbeddedResource], currentConfig *settings.Config, services MCPAvailableServices, callToolReq *MCPCallToolRequest) (any, *errs.Error) { - result, err := handler.Handle(c, callToolReq, currentConfig, services) - - if err != nil { - return nil, errs.Or(err, errs.ErrOperationFailed) - } - - callToolResp := MCPCallToolResponse[MCPEmbeddedResource]{ - Content: result, - IsError: false, - } - - return callToolResp, nil -} - -func getMCPToolInfo[T MCPTextContent | MCPImageContent | MCPAudioContent | MCPResourceLink | MCPEmbeddedResource](name string, handler MCPToolHandler[T]) *MCPTool { - mcpTool := &MCPTool{ - Name: name, - Description: handler.Description(), - } - - schemeGenerator := jsonschema.Reflector{ - Anonymous: true, - DoNotReference: true, - ExpandedStruct: true, - } - - if handler.InputType() != nil { - schema := schemeGenerator.ReflectFromType(handler.InputType()) - mcpTool.InputSchema = schema - } - - if handler.OutputType() != nil { - schema := schemeGenerator.ReflectFromType(handler.OutputType()) - mcpTool.OutputSchema = schema - } - - return mcpTool + Handle(*core.WebContext, *MCPCallToolRequest, *settings.Config, MCPAvailableServices) (any, []*T, *errs.Error) } diff --git a/pkg/mcp/mcp_container.go b/pkg/mcp/mcp_container.go new file mode 100644 index 00000000..f5f17105 --- /dev/null +++ b/pkg/mcp/mcp_container.go @@ -0,0 +1,159 @@ +package mcp + +import ( + "github.com/invopop/jsonschema" + orderedmap "github.com/wk8/go-ordered-map/v2" + + "github.com/mayswind/ezbookkeeping/pkg/core" + "github.com/mayswind/ezbookkeeping/pkg/errs" + "github.com/mayswind/ezbookkeeping/pkg/settings" +) + +// MCPContainer contains the all mcp handlers +type MCPContainer struct { + mcpTextContentTools *orderedmap.OrderedMap[string, MCPToolHandler[MCPTextContent]] + mcpImageContentTools *orderedmap.OrderedMap[string, MCPToolHandler[MCPImageContent]] + mcpAudioContentTools *orderedmap.OrderedMap[string, MCPToolHandler[MCPAudioContent]] + mcpResourceLinkTools *orderedmap.OrderedMap[string, MCPToolHandler[MCPResourceLink]] + mcpEmbeddedResourceTools *orderedmap.OrderedMap[string, MCPToolHandler[MCPEmbeddedResource]] + mcpTools []*MCPTool +} + +// Initialize a mcp handler container singleton instance +var ( + Container = &MCPContainer{} +) + +// GetMCPTools returns the registered MCP tools +func (c *MCPContainer) GetMCPTools() []*MCPTool { + if len(c.mcpTools) == 0 { + return nil + } + + return c.mcpTools +} + +// HandleTool returns the result of the MCP tool handler based on the tool name +func (c *MCPContainer) HandleTool(ctx *core.WebContext, callToolReq *MCPCallToolRequest, currentConfig *settings.Config, services MCPAvailableServices) (any, *errs.Error) { + if handler, exists := c.mcpTextContentTools.Get(callToolReq.Name); exists { + return handleTool(ctx, handler, currentConfig, services, callToolReq) + } + + if handler, exists := c.mcpImageContentTools.Get(callToolReq.Name); exists { + return handleTool(ctx, handler, currentConfig, services, callToolReq) + } + + if handler, exists := c.mcpAudioContentTools.Get(callToolReq.Name); exists { + return handleTool(ctx, handler, currentConfig, services, callToolReq) + } + + if handler, exists := c.mcpResourceLinkTools.Get(callToolReq.Name); exists { + return handleTool(ctx, handler, currentConfig, services, callToolReq) + } + + if handler, exists := c.mcpEmbeddedResourceTools.Get(callToolReq.Name); exists { + return handleTool(ctx, handler, currentConfig, services, callToolReq) + } + + return nil, errs.ErrApiNotFound +} + +// InitializeMCPHandlers initializes the all mcp handlers according to the config +func InitializeMCPHandlers(config *settings.Config) error { + container := &MCPContainer{ + mcpTextContentTools: orderedmap.New[string, MCPToolHandler[MCPTextContent]](), + mcpImageContentTools: orderedmap.New[string, MCPToolHandler[MCPImageContent]](), + mcpAudioContentTools: orderedmap.New[string, MCPToolHandler[MCPAudioContent]](), + mcpResourceLinkTools: orderedmap.New[string, MCPToolHandler[MCPResourceLink]](), + mcpEmbeddedResourceTools: orderedmap.New[string, MCPToolHandler[MCPEmbeddedResource]](), + mcpTools: make([]*MCPTool, 0), + } + + registerMCPTextContentToolHandler(container, MCPQueryLatestExchangeRatesToolHandler) + + Container = container + return nil +} + +func registerMCPTextContentToolHandler(c *MCPContainer, handler MCPToolHandler[MCPTextContent]) { + registerMCPToolHandler(c, c.mcpTextContentTools, handler) +} + +func registerMCPImageContentToolHandler(c *MCPContainer, handler MCPToolHandler[MCPImageContent]) { + registerMCPToolHandler(c, c.mcpImageContentTools, handler) +} + +func registerMCPAudioContentToolHandler(c *MCPContainer, handler MCPToolHandler[MCPAudioContent]) { + registerMCPToolHandler(c, c.mcpAudioContentTools, handler) +} + +func registerMCPResourceLinkToolHandler(c *MCPContainer, handler MCPToolHandler[MCPResourceLink]) { + registerMCPToolHandler(c, c.mcpResourceLinkTools, handler) +} + +func registerMCPEmbeddedResourceToolHandler(c *MCPContainer, handler MCPToolHandler[MCPEmbeddedResource]) { + registerMCPToolHandler(c, c.mcpEmbeddedResourceTools, handler) +} + +func registerMCPToolHandler[T MCPTextContent | MCPImageContent | MCPAudioContent | MCPResourceLink | MCPEmbeddedResource](c *MCPContainer, mcpToolHandlerMap *orderedmap.OrderedMap[string, MCPToolHandler[T]], handler MCPToolHandler[T]) { + if _, exists := mcpToolHandlerMap.Get(handler.Name()); exists { + return + } + + mcpToolHandlerMap.Set(handler.Name(), handler) + c.mcpTools = append(c.mcpTools, createNewMCPToolInfo(handler.Name(), handler)) +} + +func handleTool[T MCPTextContent | MCPImageContent | MCPAudioContent | MCPResourceLink | MCPEmbeddedResource](ctx *core.WebContext, handler MCPToolHandler[T], currentConfig *settings.Config, services MCPAvailableServices, callToolReq *MCPCallToolRequest) (any, *errs.Error) { + structuredResponse, result, err := handler.Handle(ctx, callToolReq, currentConfig, services) + + if err != nil { + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + callToolResp := MCPCallToolResponse[T]{ + Content: result, + IsError: false, + } + + if ctx.GetHeader(MCPProtocolVersionHeaderName) > string(ToolResultStructuredContentMinVersion) { + callToolResp.StructuredContent = structuredResponse + } + + return callToolResp, nil +} + +func createNewMCPToolInfo[T MCPTextContent | MCPImageContent | MCPAudioContent | MCPResourceLink | MCPEmbeddedResource](name string, handler MCPToolHandler[T]) *MCPTool { + mcpTool := &MCPTool{ + Name: name, + Description: handler.Description(), + } + + schemeGenerator := jsonschema.Reflector{ + Anonymous: true, + DoNotReference: true, + ExpandedStruct: true, + } + + if handler.InputType() != nil { + schema := schemeGenerator.ReflectFromType(handler.InputType()) + schema.Version = "" + mcpTool.InputSchema = schema + } else { + mcpTool.InputSchema = &jsonschema.Schema{ + Type: "object", + } + } + + if handler.OutputType() != nil { + schema := schemeGenerator.ReflectFromType(handler.OutputType()) + schema.Version = "" + mcpTool.OutputSchema = schema + } else { + mcpTool.OutputSchema = &jsonschema.Schema{ + Type: "object", + } + } + + return mcpTool +} diff --git a/pkg/mcp/model_context_protocol.go b/pkg/mcp/model_context_protocol.go index 7996b862..cbfa7d68 100644 --- a/pkg/mcp/model_context_protocol.go +++ b/pkg/mcp/model_context_protocol.go @@ -20,6 +20,12 @@ const ( // LatestSupportedMCPVersion defines the latest supported version of Model Context Protocol (MCP) const LatestSupportedMCPVersion = MCPProtocolVersion20250618 +// ToolResultStructuredContentMinVersion defines the minimum version of structured content supported in tool results +const ToolResultStructuredContentMinVersion = MCPProtocolVersion20250618 + +// MCPProtocolVersionHeaderName defines the HTTP header name for the MCP protocol version +const MCPProtocolVersionHeaderName = "MCP-Protocol-Version" + // SupportedMCPVersion defines a map of supported MCP versions var SupportedMCPVersion = map[MCPProtocolVersion]bool{ MCPProtocolVersion20250618: true, @@ -120,7 +126,7 @@ type MCPListToolsResponse struct { type MCPTool struct { Name string `json:"name"` InputSchema *jsonschema.Schema `json:"inputSchema"` - OutputSchema *jsonschema.Schema `json:"outputSchema"` + OutputSchema *jsonschema.Schema `json:"outputSchema,omitempty"` Title string `json:"title,omitempty"` Description string `json:"description,omitempty"` } @@ -133,8 +139,9 @@ type MCPCallToolRequest struct { // MCPCallToolResponse defines the response structure for calling a tool in the MCP type MCPCallToolResponse[T MCPTextContent | MCPImageContent | MCPAudioContent | MCPResourceLink | MCPEmbeddedResource] struct { - Content []*T `json:"content"` - IsError bool `json:"isError,omitempty"` + Content []*T `json:"content"` + StructuredContent any `json:"structuredContent,omitempty"` + IsError bool `json:"isError,omitempty"` } // MCPTextContent defines the text content structure used in MCP diff --git a/pkg/mcp/query_latest_exchange_rates.go b/pkg/mcp/query_latest_exchange_rates.go index c7d047c5..8f1099dc 100644 --- a/pkg/mcp/query_latest_exchange_rates.go +++ b/pkg/mcp/query_latest_exchange_rates.go @@ -29,7 +29,7 @@ type MCPQueryExchangeRatesResponse struct { // MCPQueryExchangeRateInfo defines the structure of exchange rate information for a specific currency type MCPQueryExchangeRateInfo struct { Currency string `json:"currency" jsonschema_description:"Currency code (e.g. USD)"` - Rate string `json:"rate" jsonschema_description:"The amount of the base currency that can be exchanged for 1 of this currency"` + Rate string `json:"rate_to_base" jsonschema_description:"The amount of the base currency that can be obtained for 1 unit of this currency"` } type mcpQueryLatestExchangeRatesToolHandler struct{} @@ -57,39 +57,39 @@ func (h *mcpQueryLatestExchangeRatesToolHandler) OutputType() reflect.Type { } // Handle processes the MCP call tool request and returns the response -func (h *mcpQueryLatestExchangeRatesToolHandler) Handle(c *core.WebContext, callToolReq *MCPCallToolRequest, currentConfig *settings.Config, services MCPAvailableServices) ([]*MCPTextContent, *errs.Error) { +func (h *mcpQueryLatestExchangeRatesToolHandler) Handle(c *core.WebContext, callToolReq *MCPCallToolRequest, currentConfig *settings.Config, services MCPAvailableServices) (any, []*MCPTextContent, *errs.Error) { var exchangeRatesRequest MCPQueryExchangeRatesRequest if callToolReq.Arguments != nil { if err := json.Unmarshal(callToolReq.Arguments, &exchangeRatesRequest); err != nil { - return nil, errs.NewIncompleteOrIncorrectSubmissionError(err) + return nil, nil, errs.NewIncompleteOrIncorrectSubmissionError(err) } } else { - return nil, errs.ErrIncompleteOrIncorrectSubmission + return nil, nil, errs.ErrIncompleteOrIncorrectSubmission } dataSource := exchangerates.Container.Current if dataSource == nil { - return nil, errs.ErrInvalidExchangeRatesDataSource + return nil, nil, errs.ErrInvalidExchangeRatesDataSource } exchangeRateResponse, err := dataSource.GetLatestExchangeRates(c, c.GetCurrentUid(), currentConfig) if err != nil { - return nil, errs.Or(err, errs.ErrOperationFailed) + return nil, nil, errs.Or(err, errs.ErrOperationFailed) } - response, err := h.createNewMCPQueryExchangeRatesResponse(exchangeRatesRequest.Currencies, exchangeRateResponse) + structuredResponse, response, err := h.createNewMCPQueryExchangeRatesResponse(exchangeRatesRequest.Currencies, exchangeRateResponse) if err != nil { - return nil, errs.Or(err, errs.ErrOperationFailed) + return nil, nil, errs.Or(err, errs.ErrOperationFailed) } - return response, nil + return structuredResponse, response, nil } -func (h *mcpQueryLatestExchangeRatesToolHandler) createNewMCPQueryExchangeRatesResponse(currencies string, exchangeRatesResp *models.LatestExchangeRateResponse) ([]*MCPTextContent, error) { +func (h *mcpQueryLatestExchangeRatesToolHandler) createNewMCPQueryExchangeRatesResponse(currencies string, exchangeRatesResp *models.LatestExchangeRateResponse) (any, []*MCPTextContent, error) { queryCurrencies := make(map[string]bool) for _, currency := range strings.Split(currencies, ",") { @@ -102,7 +102,7 @@ func (h *mcpQueryLatestExchangeRatesToolHandler) createNewMCPQueryExchangeRatesR response := &MCPQueryExchangeRatesResponse{ BaseCurrency: exchangeRatesResp.BaseCurrency, - UpdateTime: utils.FormatUnixTimeToLongDateTimeWithTimezoneRFC3389Format(exchangeRatesResp.UpdateTime, time.UTC), + UpdateTime: utils.FormatUnixTimeToLongDateTimeWithTimezoneRFC3339Format(exchangeRatesResp.UpdateTime, time.UTC), Rates: make([]*MCPQueryExchangeRateInfo, 0, len(exchangeRatesResp.ExchangeRates)), } @@ -120,10 +120,10 @@ func (h *mcpQueryLatestExchangeRatesToolHandler) createNewMCPQueryExchangeRatesR content, err := json.Marshal(response) if err != nil { - return nil, err + return nil, nil, err } - return []*MCPTextContent{ + return response, []*MCPTextContent{ NewMCPTextContent(string(content)), }, nil } diff --git a/pkg/utils/datetimes.go b/pkg/utils/datetimes.go index 7a4d5304..960bb2ce 100644 --- a/pkg/utils/datetimes.go +++ b/pkg/utils/datetimes.go @@ -13,7 +13,7 @@ const ( longDateTimeFormat = "2006-01-02 15:04:05" longDateTimeWithTimezoneFormat = "2006-01-02 15:04:05Z07:00" longDateTimeWithTimezoneFormat2 = "2006-01-02 15:04:05 Z0700" - longDateTimeWithTimezoneRFC3389Format = "2006-01-02T15:04:05Z07:00" + longDateTimeWithTimezoneRFC3339Format = "2006-01-02T15:04:05Z07:00" longDateTimeWithoutSecondFormat = "2006-01-02 15:04" shortDateTimeFormat = "2006-1-2 15:4:5" yearMonthDateTimeFormat = "2006-01" @@ -77,15 +77,15 @@ func FormatUnixTimeToLongDateTimeWithTimezone(unixTime int64, timezone *time.Loc return t.Format(longDateTimeWithTimezoneFormat) } -// FormatUnixTimeToLongDateTimeWithTimezoneRFC3389Format returns a textual representation of the unix time formatted by long date time with timezone RFC 3389 format -func FormatUnixTimeToLongDateTimeWithTimezoneRFC3389Format(unixTime int64, timezone *time.Location) string { +// FormatUnixTimeToLongDateTimeWithTimezoneRFC3339Format returns a textual representation of the unix time formatted by long date time with timezone RFC 3339 format +func FormatUnixTimeToLongDateTimeWithTimezoneRFC3339Format(unixTime int64, timezone *time.Location) string { t := parseFromUnixTime(unixTime) if timezone != nil { t = t.In(timezone) } - return t.Format(longDateTimeWithTimezoneRFC3389Format) + return t.Format(longDateTimeWithTimezoneRFC3339Format) } func FormatYearMonthDayToLongDateTime(year string, month string, day string) (string, error) { @@ -229,6 +229,11 @@ func ParseFromLongDateTimeWithTimezone2(t string) (time.Time, error) { return time.Parse(longDateTimeWithTimezoneFormat2, t) } +// ParseFromLongDateTimeWithTimezoneRFC3339Format parses a formatted string in long date time RFC 3378 format +func ParseFromLongDateTimeWithTimezoneRFC3339Format(t string) (time.Time, error) { + return time.Parse(longDateTimeWithTimezoneRFC3339Format, t) +} + // ParseFromLongDateTimeWithoutSecond parses a formatted string in long date time format (no second) func ParseFromLongDateTimeWithoutSecond(t string, utcOffset int16) (time.Time, error) { timezone := time.FixedZone("Timezone", int(utcOffset)*60) diff --git a/pkg/utils/datetimes_test.go b/pkg/utils/datetimes_test.go index f2bd25c3..e1dda405 100644 --- a/pkg/utils/datetimes_test.go +++ b/pkg/utils/datetimes_test.go @@ -46,17 +46,17 @@ func TestFormatUnixTimeToLongDateTimeWithTimezone(t *testing.T) { assert.Equal(t, expectedValue, actualValue) } -func TestFormatUnixTimeToLongDateTimeWithTimezoneRFC3389Format(t *testing.T) { +func TestFormatUnixTimeToLongDateTimeWithTimezoneRFC3339Format(t *testing.T) { unixTime := int64(1617228083) utcTimezone := time.FixedZone("Test Timezone", 0) // UTC utc8Timezone := time.FixedZone("Test Timezone", 28800) // UTC+8 expectedValue := "2021-03-31T22:01:23Z" - actualValue := FormatUnixTimeToLongDateTimeWithTimezoneRFC3389Format(unixTime, utcTimezone) + actualValue := FormatUnixTimeToLongDateTimeWithTimezoneRFC3339Format(unixTime, utcTimezone) assert.Equal(t, expectedValue, actualValue) expectedValue = "2021-04-01T06:01:23+08:00" - actualValue = FormatUnixTimeToLongDateTimeWithTimezoneRFC3389Format(unixTime, utc8Timezone) + actualValue = FormatUnixTimeToLongDateTimeWithTimezoneRFC3339Format(unixTime, utc8Timezone) assert.Equal(t, expectedValue, actualValue) } @@ -228,6 +228,15 @@ func TestParseFromLongDateTimeWithTimezone2(t *testing.T) { assert.Equal(t, expectedValue, actualValue) } +func TestParseFromLongDateTimeWithTimezoneRFC3339Format(t *testing.T) { + expectedValue := int64(1617238883) + actualTime, err := ParseFromLongDateTimeWithTimezoneRFC3339Format("2021-04-01T06:01:23+05:00") + assert.Equal(t, nil, err) + + actualValue := actualTime.Unix() + assert.Equal(t, expectedValue, actualValue) +} + func TestParseFromLongDateTimeWithoutSecond(t *testing.T) { expectedValue := int64(1691947440) actualTime, err := ParseFromLongDateTimeWithoutSecond("2023-08-13 17:24", 0)