diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..7d05e99 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ +# 依赖于环境的 Maven 主目录路径 +/mavenHomeManager.xml +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/fred-api.iml b/.idea/fred-api.iml new file mode 100644 index 0000000..25ed3f6 --- /dev/null +++ b/.idea/fred-api.iml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..1945ce5 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..6116095 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/handlers/fred.go b/handlers/fred.go index 9416b50..7da3b10 100644 --- a/handlers/fred.go +++ b/handlers/fred.go @@ -43,25 +43,141 @@ func (h *FREDHandler) buildURL(endpoint string, params map[string]string) string return u.String() } -func (h *FREDHandler) fetchData(url string, target interface{}) error { +func (h *FREDHandler) fetchData(target interface{}, c *gin.Context, endpoint string, params map[string]string) { + url := h.buildURL(endpoint, params) + resp, err := http.Get(url) if err != nil { - return err + c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + ErrorCode: "NetworkError", + ErrorMessage: err.Error(), + }) + return } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { - return err + c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + ErrorCode: "ReadError", + ErrorMessage: err.Error(), + }) + return } if resp.StatusCode != http.StatusOK { var errResp models.ErrorResponse json.Unmarshal(body, &errResp) - return fmt.Errorf("API error: %s - %s", errResp.ErrorCode, errResp.ErrorMessage) + c.JSON(http.StatusBadGateway, models.ErrorResponse{ + ErrorCode: "APIError", + ErrorMessage: fmt.Sprintf("Status: %d, Error: %s - %s", resp.StatusCode, errResp.ErrorCode, errResp.ErrorMessage), + }) + return } - return json.Unmarshal(body, target) + if err := json.Unmarshal(body, target); err != nil { + c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + ErrorCode: "ParseError", + ErrorMessage: err.Error(), + }) + return + } + + c.JSON(http.StatusOK, target) +} + +func (h *FREDHandler) HealthCheck(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "status": "healthy", + "service": "FRED API", + }) +} + +func (h *FREDHandler) GetCategory(c *gin.Context) { + categoryID := c.Query("category_id") + if categoryID == "" { + c.JSON(http.StatusBadRequest, models.ErrorResponse{ + ErrorCode: "BadRequest", + ErrorMessage: "category_id is required", + }) + return + } + h.fetchData(&models.CategoryResponse{}, c, "/category", map[string]string{"category_id": categoryID}) +} + +func (h *FREDHandler) GetCategoryChildren(c *gin.Context) { + categoryID := c.Query("category_id") + if categoryID == "" { + c.JSON(http.StatusBadRequest, models.ErrorResponse{ + ErrorCode: "BadRequest", + ErrorMessage: "category_id is required", + }) + return + } + params := map[string]string{"category_id": categoryID} + h.fetchData(&models.CategoriesResponse{}, c, "/category/children", params) +} + +func (h *FREDHandler) GetCategoryRelated(c *gin.Context) { + categoryID := c.Query("category_id") + if categoryID == "" { + c.JSON(http.StatusBadRequest, models.ErrorResponse{ + ErrorCode: "BadRequest", + ErrorMessage: "category_id is required", + }) + return + } + h.fetchData(&models.RelatedTagsResponse{}, c, "/category/related", map[string]string{"category_id": categoryID}) +} + +func (h *FREDHandler) GetCategorySeries(c *gin.Context) { + categoryID := c.Query("category_id") + if categoryID == "" { + c.JSON(http.StatusBadRequest, models.ErrorResponse{ + ErrorCode: "BadRequest", + ErrorMessage: "category_id is required", + }) + return + } + params := map[string]string{"category_id": categoryID} + h.addOptionalParams(c, params, []string{"realtime_start", "realtime_end", "limit", "offset", "order_by", "sort_order"}) + h.fetchData(&models.CategorySeriesResponse{}, c, "/category/series", params) +} + +func (h *FREDHandler) GetCategoryTags(c *gin.Context) { + categoryID := c.Query("category_id") + if categoryID == "" { + c.JSON(http.StatusBadRequest, models.ErrorResponse{ + ErrorCode: "BadRequest", + ErrorMessage: "category_id is required", + }) + return + } + params := map[string]string{"category_id": categoryID} + h.addOptionalParams(c, params, []string{"realtime_start", "realtime_end", "limit", "offset", "order_by", "sort_order"}) + h.fetchData(&models.CategoryTagsResponse{}, c, "/category/tags", params) +} + +func (h *FREDHandler) GetCategoryRelatedTags(c *gin.Context) { + categoryID := c.Query("category_id") + if categoryID == "" { + c.JSON(http.StatusBadRequest, models.ErrorResponse{ + ErrorCode: "BadRequest", + ErrorMessage: "category_id is required", + }) + return + } + tagNames := c.Query("tag_names") + if tagNames == "" { + c.JSON(http.StatusBadRequest, models.ErrorResponse{ + ErrorCode: "BadRequest", + ErrorMessage: "tag_names is required", + }) + return + } + params := map[string]string{"category_id": categoryID, "tag_names": tagNames} + h.addOptionalParams(c, params, []string{"realtime_start", "realtime_end", "limit", "offset", "order_by", "sort_order"}) + h.fetchData(&models.RelatedTagsResponse{}, c, "/category/related_tags", params) } func (h *FREDHandler) GetSeries(c *gin.Context) { @@ -73,30 +189,23 @@ func (h *FREDHandler) GetSeries(c *gin.Context) { }) return } + params := map[string]string{"series_id": seriesID} + h.addOptionalParams(c, params, []string{"realtime_start", "realtime_end"}) + h.fetchData(&models.SeriesResponse{}, c, "/series", params) +} - params := map[string]string{ - "series_id": seriesID, - } - - if realtimeStart := c.Query("realtime_start"); realtimeStart != "" { - params["realtime_start"] = realtimeStart - } - if realtimeEnd := c.Query("realtime_end"); realtimeEnd != "" { - params["realtime_end"] = realtimeEnd - } - - url := h.buildURL("/series", params) - - var response models.SeriesListResponse - if err := h.fetchData(url, &response); err != nil { - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ - ErrorCode: "InternalError", - ErrorMessage: err.Error(), +func (h *FREDHandler) GetSeriesCategories(c *gin.Context) { + seriesID := c.Query("series_id") + if seriesID == "" { + c.JSON(http.StatusBadRequest, models.ErrorResponse{ + ErrorCode: "BadRequest", + ErrorMessage: "series_id is required", }) return } - - c.JSON(http.StatusOK, response) + params := map[string]string{"series_id": seriesID} + h.addOptionalParams(c, params, []string{"realtime_start", "realtime_end"}) + h.fetchData(&models.SeriesCategoriesResponse{}, c, "/series/categories", params) } func (h *FREDHandler) GetObservations(c *gin.Context) { @@ -108,35 +217,27 @@ func (h *FREDHandler) GetObservations(c *gin.Context) { }) return } - - params := map[string]string{ - "series_id": seriesID, - } - - optionalParams := []string{ + params := map[string]string{"series_id": seriesID} + h.addOptionalParams(c, params, []string{ "realtime_start", "realtime_end", "limit", "offset", "sort_order", "observation_start", "observation_end", "units", "frequency", "aggregation_method", "output_type", "vintage_dates", - } + }) + h.fetchData(&models.ObservationsResponse{}, c, "/series/observations", params) +} - for _, param := range optionalParams { - if v := c.Query(param); v != "" { - params[param] = v - } - } - - url := h.buildURL("/series/observations", params) - - var response models.ObservationsResponse - if err := h.fetchData(url, &response); err != nil { - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ - ErrorCode: "InternalError", - ErrorMessage: err.Error(), +func (h *FREDHandler) GetSeriesRelease(c *gin.Context) { + seriesID := c.Query("series_id") + if seriesID == "" { + c.JSON(http.StatusBadRequest, models.ErrorResponse{ + ErrorCode: "BadRequest", + ErrorMessage: "series_id is required", }) return } - - c.JSON(http.StatusOK, response) + params := map[string]string{"series_id": seriesID} + h.addOptionalParams(c, params, []string{"realtime_start", "realtime_end"}) + h.fetchData(&models.ReleaseResponse{}, c, "/series/release", params) } func (h *FREDHandler) SearchSeries(c *gin.Context) { @@ -148,90 +249,135 @@ func (h *FREDHandler) SearchSeries(c *gin.Context) { }) return } - - params := map[string]string{ - "search_text": searchText, - } - - optionalParams := []string{ + params := map[string]string{"search_text": searchText} + h.addOptionalParams(c, params, []string{ "realtime_start", "realtime_end", "limit", "offset", "sort_order", - "observation_start", "observation_end", "units", "frequency", - "aggregation_method", "output_type", - } + "order_by", "filter", "" + + "" + + "" + + "" + + "" + + "", + }) + h.fetchData(&models.SearchResponse{}, c, "/series/search", params) +} - for _, param := range optionalParams { - if v := c.Query(param); v != "" { - params[param] = v - } - } - - url := h.buildURL("/series/search", params) - - var response models.SearchResponse - if err := h.fetchData(url, &response); err != nil { - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ - ErrorCode: "InternalServerError", - ErrorMessage: err.Error(), +func (h *FREDHandler) SearchSeriesTags(c *gin.Context) { + searchText := c.Query("search_text") + if searchText == "" { + c.JSON(http.StatusBadRequest, models.ErrorResponse{ + ErrorCode: "BadRequest", + ErrorMessage: "search_text is required", }) return } + params := map[string]string{"search_text": searchText} + h.addOptionalParams(c, params, []string{"realtime_start", "realtime_end", "limit", "offset", "order_by", "sort_order"}) + h.fetchData(&models.TagsResponse{}, c, "/series/search/tags", params) +} - c.JSON(http.StatusOK, response) +func (h *FREDHandler) SearchSeriesRelatedTags(c *gin.Context) { + searchText := c.Query("search_text") + if searchText == "" { + c.JSON(http.StatusBadRequest, models.ErrorResponse{ + ErrorCode: "BadRequest", + ErrorMessage: "search_text is required", + }) + return + } + tagNames := c.Query("tag_names") + if tagNames == "" { + c.JSON(http.StatusBadRequest, models.ErrorResponse{ + ErrorCode: "BadRequest", + ErrorMessage: "tag_names is required", + }) + return + } + params := map[string]string{"search_text": searchText, "tag_names": tagNames} + h.addOptionalParams(c, params, []string{"realtime_start", "realtime_end", "limit", "offset", "order_by", "sort_order"}) + h.fetchData(&models.RelatedTagsResponse{}, c, "/series/search/related_tags", params) +} + +func (h *FREDHandler) GetSeriesTags(c *gin.Context) { + seriesID := c.Query("series_id") + if seriesID == "" { + c.JSON(http.StatusBadRequest, models.ErrorResponse{ + ErrorCode: "BadRequest", + ErrorMessage: "series_id is required", + }) + return + } + params := map[string]string{"series_id": seriesID} + h.addOptionalParams(c, params, []string{"realtime_start", "realtime_end", "limit", "offset", "order_by", "sort_order"}) + h.fetchData(&models.SeriesTagsResponse{}, c, "/series/tags", params) +} + +func (h *FREDHandler) GetSeriesUpdates(c *gin.Context) { + params := map[string]string{} + h.addOptionalParams(c, params, []string{ + "realtime_start", "realtime_end", "limit", "offset", "sort_order", + "filter", "observation_start", "observation_end", + }) + h.fetchData(&models.SeriesUpdatesResponse{}, c, "/series/updates", params) +} + +func (h *FREDHandler) GetSeriesVintageDates(c *gin.Context) { + seriesID := c.Query("series_id") + if seriesID == "" { + c.JSON(http.StatusBadRequest, models.ErrorResponse{ + ErrorCode: "BadRequest", + ErrorMessage: "series_id is required", + }) + return + } + params := map[string]string{"series_id": seriesID} + h.addOptionalParams(c, params, []string{"realtime_start", "realtime_end"}) + h.fetchData(&models.VintageDatesResponse{}, c, "/series/vintagedates", params) } func (h *FREDHandler) GetReleases(c *gin.Context) { params := map[string]string{} - - optionalParams := []string{ - "realtime_start", "realtime_end", "limit", "offset", "sort_order", - } - - for _, param := range optionalParams { - if v := c.Query(param); v != "" { - params[param] = v - } - } - - url := h.buildURL("/releases", params) - - var response models.ReleasesResponse - if err := h.fetchData(url, &response); err != nil { - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ - ErrorCode: "InternalServerError", - ErrorMessage: err.Error(), - }) - return - } - - c.JSON(http.StatusOK, response) + h.addOptionalParams(c, params, []string{"realtime_start", "realtime_end", "limit", "offset", "sort_order"}) + h.fetchData(&models.ReleasesResponse{}, c, "/releases", params) } func (h *FREDHandler) GetReleaseDates(c *gin.Context) { params := map[string]string{} - - optionalParams := []string{ + h.addOptionalParams(c, params, []string{ "realtime_start", "realtime_end", "limit", "offset", "sort_order", "release_id", "date", "start_date", "end_date", - } + }) + h.fetchData(&models.ReleaseDatesResponse{}, c, "/releases/dates", params) +} - for _, param := range optionalParams { - if v := c.Query(param); v != "" { - params[param] = v - } - } - - url := h.buildURL("/releases/dates", params) - - var response models.ReleaseDatesResponse - if err := h.fetchData(url, &response); err != nil { - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ - ErrorCode: "InternalServerError", - ErrorMessage: err.Error(), +func (h *FREDHandler) GetRelease(c *gin.Context) { + releaseIDStr := c.Query("release_id") + if releaseIDStr == "" { + c.JSON(http.StatusBadRequest, models.ErrorResponse{ + ErrorCode: "BadRequest", + ErrorMessage: "release_id is required", }) return } + releaseID, _ := strconv.Atoi(releaseIDStr) + params := map[string]string{"release_id": strconv.Itoa(releaseID)} + h.addOptionalParams(c, params, []string{"realtime_start", "realtime_end"}) + h.fetchData(&models.ReleaseResponse{}, c, "/release", params) +} - c.JSON(http.StatusOK, response) +func (h *FREDHandler) GetReleaseDates2(c *gin.Context) { + releaseIDStr := c.Query("release_id") + if releaseIDStr == "" { + c.JSON(http.StatusBadRequest, models.ErrorResponse{ + ErrorCode: "BadRequest", + ErrorMessage: "release_id is required", + }) + return + } + releaseID, _ := strconv.Atoi(releaseIDStr) + params := map[string]string{"release_id": strconv.Itoa(releaseID)} + h.addOptionalParams(c, params, []string{"realtime_start", "realtime_end", "limit", "offset", "sort_order", "date", "start_date", "end_date"}) + h.fetchData(&models.ReleaseDatesResponse{}, c, "/release/dates", params) } func (h *FREDHandler) GetReleaseSeries(c *gin.Context) { @@ -243,49 +389,171 @@ func (h *FREDHandler) GetReleaseSeries(c *gin.Context) { }) return } + releaseID, _ := strconv.Atoi(releaseIDStr) + params := map[string]string{"release_id": strconv.Itoa(releaseID)} + h.addOptionalParams(c, params, []string{ + "realtime_start", "realtime_end", "limit", "offset", "sort_order", + "order_by", "filter", "observation_start", "observation_end", "units", "frequency", + "aggregation_method", "output_type", + }) + h.fetchData(&models.SearchResponse{}, c, "/release/series", params) +} - releaseID, err := strconv.Atoi(releaseIDStr) - if err != nil { +func (h *FREDHandler) GetReleaseSources(c *gin.Context) { + releaseIDStr := c.Query("release_id") + if releaseIDStr == "" { c.JSON(http.StatusBadRequest, models.ErrorResponse{ ErrorCode: "BadRequest", - ErrorMessage: "release_id must be a number", + ErrorMessage: "release_id is required", }) return } + releaseID, _ := strconv.Atoi(releaseIDStr) + params := map[string]string{"release_id": strconv.Itoa(releaseID)} + h.addOptionalParams(c, params, []string{"realtime_start", "realtime_end"}) + h.fetchData(&models.ReleaseSourcesResponse{}, c, "/release/sources", params) +} - params := map[string]string{ - "release_id": strings.TrimSpace(strings.Replace(strconv.Itoa(releaseID), " ", "", -1)), +func (h *FREDHandler) GetReleaseTags(c *gin.Context) { + releaseIDStr := c.Query("release_id") + if releaseIDStr == "" { + c.JSON(http.StatusBadRequest, models.ErrorResponse{ + ErrorCode: "BadRequest", + ErrorMessage: "release_id is required", + }) + return } + releaseID, _ := strconv.Atoi(releaseIDStr) + params := map[string]string{"release_id": strconv.Itoa(releaseID)} + h.addOptionalParams(c, params, []string{"realtime_start", "realtime_end", "limit", "offset", "order_by", "sort_order"}) + h.fetchData(&models.ReleaseTagsResponse{}, c, "/release/tags", params) +} - optionalParams := []string{ - "realtime_start", "realtime_end", "limit", "offset", "sort_order", - "observation_start", "observation_end", "units", "frequency", - "aggregation_method", "output_type", +func (h *FREDHandler) GetReleaseRelatedTags(c *gin.Context) { + releaseIDStr := c.Query("release_id") + if releaseIDStr == "" { + c.JSON(http.StatusBadRequest, models.ErrorResponse{ + ErrorCode: "BadRequest", + ErrorMessage: "release_id is required", + }) + return } + tagNames := c.Query("tag_names") + if tagNames == "" { + c.JSON(http.StatusBadRequest, models.ErrorResponse{ + ErrorCode: "BadRequest", + ErrorMessage: "tag_names is required", + }) + return + } + releaseID, _ := strconv.Atoi(releaseIDStr) + params := map[string]string{"release_id": strconv.Itoa(releaseID), "tag_names": tagNames} + h.addOptionalParams(c, params, []string{"realtime_start", "realtime_end", "limit", "offset", "order_by", "sort_order"}) + h.fetchData(&models.RelatedTagsResponse{}, c, "/release/related_tags", params) +} - for _, param := range optionalParams { +func (h *FREDHandler) GetReleaseTables(c *gin.Context) { + releaseIDStr := c.Query("release_id") + if releaseIDStr == "" { + c.JSON(http.StatusBadRequest, models.ErrorResponse{ + ErrorCode: "BadRequest", + ErrorMessage: "release_id is required", + }) + return + } + releaseID, _ := strconv.Atoi(releaseIDStr) + params := map[string]string{"release_id": strconv.Itoa(releaseID)} + h.addOptionalParams(c, params, []string{"realtime_start", "realtime_end"}) + h.fetchData(&models.ReleaseTablesResponse{}, c, "/release/tables", params) +} + +func (h *FREDHandler) GetSources(c *gin.Context) { + params := map[string]string{} + h.addOptionalParams(c, params, []string{"realtime_start", "realtime_end", "limit", "offset", "sort_order"}) + h.fetchData(&models.SourcesResponse{}, c, "/sources", params) +} + +func (h *FREDHandler) GetSource(c *gin.Context) { + sourceIDStr := c.Query("source_id") + if sourceIDStr == "" { + c.JSON(http.StatusBadRequest, models.ErrorResponse{ + ErrorCode: "BadRequest", + ErrorMessage: "source_id is required", + }) + return + } + sourceID, _ := strconv.Atoi(sourceIDStr) + params := map[string]string{"source_id": strconv.Itoa(sourceID)} + h.addOptionalParams(c, params, []string{"realtime_start", "realtime_end"}) + h.fetchData(&models.SourceResponse{}, c, "/source", params) +} + +func (h *FREDHandler) GetSourceReleases(c *gin.Context) { + sourceIDStr := c.Query("source_id") + if sourceIDStr == "" { + c.JSON(http.StatusBadRequest, models.ErrorResponse{ + ErrorCode: "BadRequest", + ErrorMessage: "source_id is required", + }) + return + } + sourceID, _ := strconv.Atoi(sourceIDStr) + params := map[string]string{"source_id": strconv.Itoa(sourceID)} + h.addOptionalParams(c, params, []string{"realtime_start", "realtime_end", "limit", "offset", "sort_order"}) + h.fetchData(&models.ReleasesResponse{}, c, "/source/releases", params) +} + +func (h *FREDHandler) GetTags(c *gin.Context) { + params := map[string]string{} + h.addOptionalParams(c, params, []string{ + "realtime_start", "realtime_end", "limit", "offset", "order_by", "sort_order", + "tag_name", "tag_group_id", "filter", + }) + h.fetchData(&models.TagsResponse{}, c, "/tags", params) +} + +func (h *FREDHandler) GetRelatedTags(c *gin.Context) { + tagNames := c.Query("tag_names") + if tagNames == "" { + c.JSON(http.StatusBadRequest, models.ErrorResponse{ + ErrorCode: "BadRequest", + ErrorMessage: "tag_names is required", + }) + return + } + params := map[string]string{"tag_names": tagNames} + h.addOptionalParams(c, params, []string{ + "realtime_start", "realtime_end", "limit", "offset", "order_by", "sort_order", + }) + h.fetchData(&models.RelatedTagsResponse{}, c, "/related_tags", params) +} + +func (h *FREDHandler) GetTagsSeries(c *gin.Context) { + tagNames := c.Query("tag_names") + if tagNames == "" { + c.JSON(http.StatusBadRequest, models.ErrorResponse{ + ErrorCode: "BadRequest", + ErrorMessage: "tag_names is required", + }) + return + } + params := map[string]string{"tag_names": tagNames} + h.addOptionalParams(c, params, []string{ + "realtime_start", "realtime_end", "limit", "offset", "order_by", "sort_order", + "filter", "" + + "" + + "" + + "" + + "" + + "", + }) + h.fetchData(&models.SearchResponse{}, c, "/tags/series", params) +} + +func (h *FREDHandler) addOptionalParams(c *gin.Context, params map[string]string, optionals []string) { + for _, param := range optionals { if v := c.Query(param); v != "" { - params[param] = v + params[param] = strings.TrimSpace(v) } } - - url := h.buildURL("/release/series", params) - - var response models.SearchResponse - if err := h.fetchData(url, &response); err != nil { - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ - ErrorCode: "InternalServerError", - ErrorMessage: err.Error(), - }) - return - } - - c.JSON(http.StatusOK, response) -} - -func (h *FREDHandler) HealthCheck(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{ - "status": "healthy", - "service": "FRED API Proxy", - }) } diff --git a/main.go b/main.go index 88cecbb..79ed69a 100644 --- a/main.go +++ b/main.go @@ -41,25 +41,67 @@ func main() { api := router.Group("/api/v1") { + api.GET("/category", fredHandler.GetCategory) + api.GET("/category/children", fredHandler.GetCategoryChildren) + api.GET("/category/related", fredHandler.GetCategoryRelated) + api.GET("/category/series", fredHandler.GetCategorySeries) + api.GET("/category/tags", fredHandler.GetCategoryTags) + api.GET("/category/related_tags", fredHandler.GetCategoryRelatedTags) + api.GET("/series", fredHandler.GetSeries) + api.GET("/series/categories", fredHandler.GetSeriesCategories) api.GET("/series/observations", fredHandler.GetObservations) + api.GET("/series/release", fredHandler.GetSeriesRelease) api.GET("/series/search", fredHandler.SearchSeries) + api.GET("/series/search/tags", fredHandler.SearchSeriesTags) + api.GET("/series/search/related_tags", fredHandler.SearchSeriesRelatedTags) + api.GET("/series/tags", fredHandler.GetSeriesTags) + api.GET("/series/updates", fredHandler.GetSeriesUpdates) + api.GET("/series/vintagedates", fredHandler.GetSeriesVintageDates) + api.GET("/releases", fredHandler.GetReleases) api.GET("/releases/dates", fredHandler.GetReleaseDates) + api.GET("/release", fredHandler.GetRelease) + api.GET("/release/dates", fredHandler.GetReleaseDates2) api.GET("/release/series", fredHandler.GetReleaseSeries) + api.GET("/release/sources", fredHandler.GetReleaseSources) + api.GET("/release/tags", fredHandler.GetReleaseTags) + api.GET("/release/related_tags", fredHandler.GetReleaseRelatedTags) + api.GET("/release/tables", fredHandler.GetReleaseTables) + + api.GET("/sources", fredHandler.GetSources) + api.GET("/source", fredHandler.GetSource) + api.GET("/source/releases", fredHandler.GetSourceReleases) + + api.GET("/tags", fredHandler.GetTags) + api.GET("/related_tags", fredHandler.GetRelatedTags) + api.GET("/tags/series", fredHandler.GetTagsSeries) } + router.Static("/static", "./web/static") + router.LoadHTMLGlob("web/templates/*") + + router.GET("/", func(c *gin.Context) { + c.HTML(200, "index.html", gin.H{ + "title": "FRED Economic Data Dashboard", + }) + }) + go func() { fmt.Printf("FRED API Server starting on :%s\n", cfg.ServerPort) fmt.Printf("FRED API Base URL: %s\n", cfg.FREDBaseURL) fmt.Println("\nAvailable endpoints:") fmt.Println(" GET /health - Health check") + fmt.Println(" GET / - Web Dashboard") fmt.Println(" GET /api/v1/series - Get series info") fmt.Println(" GET /api/v1/series/observations - Get observations") fmt.Println(" GET /api/v1/series/search - Search series") fmt.Println(" GET /api/v1/releases - Get all releases") fmt.Println(" GET /api/v1/releases/dates - Get release dates") fmt.Println(" GET /api/v1/release/series - Get series in release") + fmt.Println(" GET /api/v1/category - Get category") + fmt.Println(" GET /api/v1/sources - Get sources") + fmt.Println(" GET /api/v1/tags - Get tags") fmt.Println("") fmt.Println("Examples:") fmt.Println(" curl 'http://localhost:" + cfg.ServerPort + "/api/v1/series?series_id=GNPCA'") diff --git a/models/models.go b/models/models.go index 30636c4..c3e0b6c 100644 --- a/models/models.go +++ b/models/models.go @@ -1,5 +1,25 @@ package models +type Category struct { + ID int `json:"id"` + Name string `json:"name"` + ParentID int `json:"parent_id"` + Level int `json:"level"` + Notes string `json:"notes,omitempty"` +} + +type CategoryResponse struct { + RealtimeStart string `json:"realtime_start"` + RealtimeEnd string `json:"realtime_end"` + Category *Category `json:"category"` +} + +type CategoriesResponse struct { + RealtimeStart string `json:"realtime_start"` + RealtimeEnd string `json:"realtime_end"` + Categories []Category `json:"categories"` +} + type Series struct { ID string `json:"id"` RealtimeStart string `json:"realtime_start"` @@ -24,6 +44,12 @@ type SeriesListResponse struct { Seriess []Series `json:"seriess"` } +type SeriesResponse struct { + RealtimeStart string `json:"realtime_start"` + RealtimeEnd string `json:"realtime_end"` + Series Series `json:"series"` +} + type Observation struct { RealtimeStart string `json:"realtime_start"` RealtimeEnd string `json:"realtime_end"` @@ -48,12 +74,19 @@ type ObservationsResponse struct { } type SearchResult struct { - ID string `json:"id"` - RealtimeStart string `json:"realtime_start"` - RealtimeEnd string `json:"realtime_end"` - Title string `json:"title"` - Frequency string `json:"frequency"` - Units string `json:"units"` + ID string `json:"id"` + RealtimeStart string `json:"realtime_start"` + RealtimeEnd string `json:"realtime_end"` + Title string `json:"title"` + Frequency string `json:"frequency"` + FrequencyShort string `json:"frequency_short"` + Units string `json:"units"` + UnitsShort string `json:"units_short"` + SeasonalAdjustment string `json:"seasonal_adjustment"` + SeasonalAdjustmentShort string `json:"seasonal_adjustment_short"` + LastUpdated string `json:"last_updated"` + Popularity int `json:"popularity"` + Notes string `json:"notes,omitempty"` } type SearchResponse struct { @@ -86,6 +119,12 @@ type ReleasesResponse struct { Releases []Release `json:"releases"` } +type ReleaseResponse struct { + RealtimeStart string `json:"realtime_start"` + RealtimeEnd string `json:"realtime_end"` + Release Release `json:"release"` +} + type ReleaseDatesResponse struct { RealtimeStart string `json:"realtime_start"` RealtimeEnd string `json:"realtime_end"` @@ -99,6 +138,130 @@ type ReleaseDatesResponse struct { } `json:"release_dates"` } +type Source struct { + ID int `json:"id"` + RealtimeStart string `json:"realtime_start"` + RealtimeEnd string `json:"realtime_end"` + Name string `json:"name"` + Link string `json:"link"` + Notes string `json:"notes,omitempty"` +} + +type SourcesResponse struct { + RealtimeStart string `json:"realtime_start"` + RealtimeEnd string `json:"realtime_end"` + Count int `json:"count"` + Offset int `json:"offset"` + Limit int `json:"limit"` + Sources []Source `json:"sources"` +} + +type SourceResponse struct { + RealtimeStart string `json:"realtime_start"` + RealtimeEnd string `json:"realtime_end"` + Source Source `json:"source"` +} + +type Tag struct { + Name string `json:"name"` + GroupName string `json:"group_name"` + Serverity string `json:"serverity"` + Notes string `json:"notes,omitempty"` + Popularity int `json:"popularity"` +} + +type TagsResponse struct { + RealtimeStart string `json:"realtime_start"` + RealtimeEnd string `json:"realtime_end"` + Count int `json:"count"` + Offset int `json:"offset"` + Limit int `json:"limit"` + Tags []Tag `json:"tags"` +} + +type RelatedTagsResponse struct { + RealtimeStart string `json:"realtime_start"` + RealtimeEnd string `json:"realtime_end"` + Count int `json:"count"` + Offset int `json:"offset"` + Limit int `json:"limit"` + RelatedTags []Tag `json:"related_tags"` +} + +type SeriesUpdate struct { + ID string `json:"id"` + RealtimeStart string `json:"realtime_start"` + RealtimeEnd string `json:"realtime_end"` + Title string `json:"title"` + Frequency string `json:"frequency"` + Units string `json:"units"` + LastUpdated string `json:"last_updated"` +} + +type SeriesUpdatesResponse struct { + RealtimeStart string `json:"realtime_start"` + RealtimeEnd string `json:"realtime_end"` + Count int `json:"count"` + Offset int `json:"offset"` + Limit int `json:"limit"` + Observations []SeriesUpdate `json:"seriess"` +} + +type VintageDatesResponse struct { + VintageDates []string `json:"vintage_dates"` +} + +type CategorySeriesResponse struct { + RealtimeStart string `json:"realtime_start"` + RealtimeEnd string `json:"realtime_end"` + Series []Series `json:"seriess"` +} + +type CategoryTagsResponse struct { + RealtimeStart string `json:"realtime_start"` + RealtimeEnd string `json:"realtime_end"` + Tags []Tag `json:"tags"` +} + +type SeriesTagsResponse struct { + RealtimeStart string `json:"realtime_start"` + RealtimeEnd string `json:"realtime_end"` + Tags []Tag `json:"tags"` +} + +type SeriesCategoriesResponse struct { + RealtimeStart string `json:"realtime_start"` + RealtimeEnd string `json:"realtime_end"` + Categories []Category `json:"categories"` +} + +type ReleaseSourcesResponse struct { + RealtimeStart string `json:"realtime_start"` + RealtimeEnd string `json:"realtime_end"` + Sources []Source `json:"sources"` +} + +type ReleaseTagsResponse struct { + RealtimeStart string `json:"realtime_start"` + RealtimeEnd string `json:"realtime_end"` + Tags []Tag `json:"tags"` +} + +type ReleaseTablesResponse struct { + Result struct { + Data []struct { + SeriesID string `json:"series_id"` + Title string `json:"title"` + StartDate string `json:"start_date"` + EndDate string `json:"end_date"` + LastUpdated string `json:"last_updated"` + Frequency string `json:"frequency"` + Units string `json:"units"` + SeasonalAdj string `json:"seasonal_adjustment"` + } `json:"data"` + } `json:"result"` +} + type ErrorResponse struct { ErrorCode string `json:"error_code"` ErrorMessage string `json:"error_message"` diff --git a/web/static/css/style.css b/web/static/css/style.css new file mode 100644 index 0000000..77c8789 --- /dev/null +++ b/web/static/css/style.css @@ -0,0 +1,137 @@ +:root { + --primary-color: #1a73e8; + --secondary-color: #5f6368; + --success-color: #34a853; + --danger-color: #ea4335; + --warning-color: #fbbc04; + --info-color: #4285f4; + --dark-bg: #202124; + --light-bg: #f8f9fa; +} + +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background-color: #f5f5f5; +} + +.navbar { + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.card { + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0,0,0,0.12); + transition: box-shadow 0.3s ease; +} + +.card:hover { + box-shadow: 0 4px 8px rgba(0,0,0,0.15); +} + +.card-header { + border-radius: 8px 8px 0 0 !important; +} + +#chart-placeholder { + display: none; +} + +#dataChart { + display: none; +} + +#dataChart.active { + display: block; +} + +.table { + font-size: 0.9rem; +} + +.table th { + position: sticky; + top: 0; + z-index: 1; +} + +.pagination { + margin: 0; +} + +.spinner-border-sm { + width: 1rem; + height: 1rem; + border-width: 0.15em; +} + +.series-item { + padding: 8px 12px; + cursor: pointer; + border-radius: 4px; + transition: background-color 0.2s; +} + +.series-item:hover { + background-color: #e8f0fe; +} + +.series-item.active { + background-color: #1a73e8; + color: white; +} + +.category-item { + padding: 6px 10px; + cursor: pointer; + font-size: 0.9rem; + border-radius: 4px; + transition: all 0.2s; +} + +.category-item:hover { + background-color: #e8f5e9; +} + +.release-item { + padding: 8px; + border-bottom: 1px solid #eee; + cursor: pointer; +} + +.release-item:hover { + background-color: #f8f9fa; +} + +.release-item:last-child { + border-bottom: none; +} + +.release-date { + font-size: 0.8rem; + color: #666; +} + +#current-time { + font-family: monospace; + font-size: 0.9rem; +} + +.btn-outline-light:hover { + background-color: rgba(255,255,255,0.2); +} + +footer { + margin-top: 2rem; +} + +.form-control:focus, .form-select:focus { + border-color: #1a73e8; + box-shadow: 0 0 0 0.2rem rgba(26, 115, 232, 0.25); +} + +.toast-container { + position: fixed; + bottom: 20px; + right: 20px; + z-index: 1050; +} \ No newline at end of file diff --git a/web/static/js/app.js b/web/static/js/app.js new file mode 100644 index 0000000..66e16ba --- /dev/null +++ b/web/static/js/app.js @@ -0,0 +1,385 @@ +const API_BASE = '/api/v1'; +let chart = null; +let currentSeriesData = []; +let currentPage = 1; +const itemsPerPage = 20; + +const popularSeries = ['GNPCA', 'GDP', 'UNRATE', 'CPIAUCSL', 'FEDFUNDS', 'M2SL', 'SP500', 'EXUSEU']; + +function init() { + updateTime(); + setInterval(updateTime, 1000); + loadPopularSeries(); + loadCategories(); + loadReleases(); + initChart(); +} + +function updateTime() { + const now = new Date(); + document.getElementById('current-time').textContent = now.toLocaleString(); +} + +function initChart() { + const ctx = document.getElementById('dataChart').getContext('2d'); + chart = new Chart(ctx, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: 'Value', + data: [], + borderColor: '#1a73e8', + backgroundColor: 'rgba(26, 115, 232, 0.1)', + fill: true, + tension: 0.3, + pointRadius: 3, + pointHoverRadius: 6 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: true, + position: 'top' + }, + tooltip: { + mode: 'index', + intersect: false + } + }, + scales: { + x: { + ticks: { + maxRotation: 45, + minRotation: 45 + } + }, + y: { + beginAtZero: false + } + }, + interaction: { + mode: 'nearest', + axis: 'x', + intersect: false + } + } + }); +} + +async function searchSeries() { + const searchText = document.getElementById('search-input').value.trim(); + if (!searchText) { + alert('Please enter a search term'); + return; + } + + try { + const response = await axios.get(`${API_BASE}/series/search`, { + params: { search_text: searchText, limit: 20 } + }); + + if (response.data && response.data.seriess && response.data.seriess.length > 0) { + displaySearchResults(response.data.seriess); + } else { + alert('No results found'); + } + } catch (error) { + console.error('Search error:', error); + alert('Search failed: ' + (error.response?.data?.error_message || error.message)); + } +} + +function displaySearchResults(results) { + const select = document.getElementById('series-select'); + select.innerHTML = ''; + + results.forEach(series => { + const option = document.createElement('option'); + option.value = series.id; + option.textContent = `${series.id} - ${series.title}`; + select.appendChild(option); + }); + + if (results.length > 0) { + select.value = results[0].id; + loadSeriesData(); + } +} + +async function loadPopularSeries() { + const select = document.getElementById('series-select'); + select.innerHTML = ''; + + popularSeries.forEach(seriesId => { + const option = document.createElement('option'); + option.value = seriesId; + option.textContent = seriesId; + select.appendChild(option); + }); + + if (popularSeries.length > 0) { + select.value = popularSeries[0]; + loadSeriesData(); + } +} + +async function loadSeriesData() { + const seriesId = document.getElementById('series-select').value; + if (!seriesId) { + document.getElementById('chart-placeholder').style.display = 'block'; + document.getElementById('dataChart').style.display = 'none'; + return; + } + + try { + const [seriesRes, obsRes] = await Promise.all([ + axios.get(`${API_BASE}/series`, { params: { series_id: seriesId } }), + axios.get(`${API_BASE}/series/observations`, { params: { series_id: seriesId, limit: 1000 } }) + ]); + + const series = seriesRes.data.series; + const observations = obsRes.data.observations || []; + + displaySeriesInfo(series); + displayChart(observations, seriesId); + currentSeriesData = observations; + renderTable(1); + updatePagination(); + + document.getElementById('chart-placeholder').style.display = 'none'; + document.getElementById('dataChart').style.display = 'block'; + } catch (error) { + console.error('Load series error:', error); + alert('Failed to load series: ' + (error.response?.data?.error_message || error.message)); + } +} + +function displaySeriesInfo(series) { + const info = document.getElementById('series-info'); + info.innerHTML = ` +
+
+

Series ID: ${series.id}

+

Title: ${series.title}

+

Frequency: ${series.frequency}

+
+
+

Units: ${series.units}

+

Seasonal Adjustment: ${series.seasonal_adjustment}

+

Last Updated: ${series.last_updated}

+
+
+ ${series.notes ? `

Notes: ${series.notes}

` : ''} + `; + document.getElementById('chart-title').textContent = series.title || series.id; +} + +function displayChart(observations, seriesId) { + const labels = observations.map(o => o.date).reverse(); + const data = observations.map(o => parseFloat(o.value) || null).reverse(); + + chart.data.labels = labels; + chart.data.datasets[0].data = data; + chart.data.datasets[0].label = seriesId; + chart.update(); +} + +function renderTable(page) { + currentPage = page; + const start = (page - 1) * itemsPerPage; + const end = start + itemsPerPage; + const pageData = currentSeriesData.slice(start, end); + + const tbody = document.getElementById('data-table-body'); + if (pageData.length === 0) { + tbody.innerHTML = 'No data available'; + return; + } + + tbody.innerHTML = pageData.map(obs => ` + + ${obs.date} + ${obs.value || 'N/A'} + + `).join(''); +} + +function updatePagination() { + const totalPages = Math.ceil(currentSeriesData.length / itemsPerPage); + const pagination = document.getElementById('pagination'); + + if (totalPages <= 1) { + pagination.innerHTML = ''; + return; + } + + let html = ''; + + if (currentPage > 1) { + html += `
  • Prev
  • `; + } + + for (let i = 1; i <= totalPages; i++) { + if (i === currentPage) { + html += `
  • ${i}
  • `; + } else if (i === 1 || i === totalPages || (i >= currentPage - 2 && i <= currentPage + 2)) { + html += `
  • ${i}
  • `; + } else if (i === currentPage - 3 || i === currentPage + 3) { + html += `
  • ...
  • `; + } + } + + if (currentPage < totalPages) { + html += `
  • Next
  • `; + } + + pagination.innerHTML = html; +} + +async function loadCategories() { + try { + const response = await axios.get(`${API_BASE}/category/children`, { + params: { category_id: 0 } + }); + + const list = document.getElementById('categories-list'); + if (response.data && response.data.categories) { + list.innerHTML = response.data.categories.map(cat => ` +
    + ${cat.name} +
    + `).join(''); + } + } catch (error) { + console.error('Load categories error:', error); + } +} + +async function loadCategorySeries(categoryId, categoryName) { + try { + const response = await axios.get(`${API_BASE}/category/series`, { + params: { category_id: categoryId, limit: 10 } + }); + + const select = document.getElementById('series-select'); + select.innerHTML = ``; + + if (response.data && response.data.seriess) { + response.data.seriess.forEach(series => { + const option = document.createElement('option'); + option.value = series.id; + option.textContent = `${series.id} - ${series.title}`; + select.appendChild(option); + }); + + if (response.data.seriess.length > 0) { + select.value = response.data.seriess[0].id; + loadSeriesData(); + } + } + } catch (error) { + console.error('Load category series error:', error); + } +} + +async function loadReleases() { + try { + const response = await axios.get(`${API_BASE}/releases/dates`, { + params: { limit: 10, start_date: getDateDaysAgo(30) } + }); + + const list = document.getElementById('releases-list'); + if (response.data && response.data.release_dates) { + list.innerHTML = response.data.release_dates.map(rel => ` +
    +
    ${rel.release_name}
    +
    ${rel.date}
    +
    + `).join(''); + } + } catch (error) { + console.error('Load releases error:', error); + } +} + +async function loadReleaseSeries(releaseId, releaseName) { + try { + const response = await axios.get(`${API_BASE}/release/series`, { + params: { release_id: releaseId, limit: 10 } + }); + + const select = document.getElementById('series-select'); + select.innerHTML = ``; + + if (response.data && response.data.seriess) { + response.data.seriess.forEach(series => { + const option = document.createElement('option'); + option.value = series.id; + option.textContent = `${series.id} - ${series.title}`; + select.appendChild(option); + }); + + if (response.data.seriess.length > 0) { + select.value = response.data.seriess[0].id; + loadSeriesData(); + } + } + } catch (error) { + console.error('Load release series error:', error); + } +} + +function showSeriesModal() { + const modal = new bootstrap.Modal(document.getElementById('addSeriesModal')); + modal.show(); +} + +function addSeriesFromModal() { + const seriesId = document.getElementById('modal-series-id').value.trim(); + if (!seriesId) { + alert('Please enter a series ID'); + return; + } + + document.getElementById('series-select').value = seriesId; + bootstrap.Modal.getInstance(document.getElementById('addSeriesModal')).hide(); + loadSeriesData(); +} + +function exportData() { + if (!currentSeriesData.length) { + alert('No data to export'); + return; + } + + let csv = 'Date,Value\n'; + currentSeriesData.forEach(obs => { + csv += `${obs.date},${obs.value}\n`; + }); + + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `fred_data_${new Date().toISOString().split('T')[0]}.csv`; + a.click(); + URL.revokeObjectURL(url); +} + +function getDateDaysAgo(days) { + const date = new Date(); + date.setDate(date.getDate() - days); + return date.toISOString().split('T')[0]; +} + +document.addEventListener('DOMContentLoaded', init); + +document.getElementById('search-input').addEventListener('keypress', function(e) { + if (e.key === 'Enter') { + searchSeries(); + } +}); \ No newline at end of file diff --git a/web/templates/index.html b/web/templates/index.html new file mode 100644 index 0000000..8d20cdb --- /dev/null +++ b/web/templates/index.html @@ -0,0 +1,183 @@ + + + + + + FRED Economic Data Dashboard + + + + + + + + + +
    +
    +
    +
    +
    +
    Search
    +
    +
    +
    + + +
    +
    + +
    +
    +
    + +
    +
    +
    Categories
    +
    +
    +
    +
    + Loading... +
    +
    +
    + +
    +
    +
    Recent Releases
    +
    +
    +
    +
    + Loading... +
    +
    +
    +
    + +
    +
    +
    +
    Select a series to view data
    +
    + +
    +
    +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +
    + +
    +
    + +

    Select or search for a series to visualize data

    +
    +
    +
    + +
    +
    +
    Data Table
    +
    +
    +
    + + + + + + + + + + + + +
    DateValue
    No data available
    +
    +
    + +
    +
    +
    + +
    +
    +
    Series Information
    +
    +
    +

    Select a series to view details

    +
    +
    +
    +
    +
    + + + + + + + + + \ No newline at end of file