diff --git a/examples/fxmacrodata_calendar/main.go b/examples/fxmacrodata_calendar/main.go new file mode 100644 index 0000000..8107fd9 --- /dev/null +++ b/examples/fxmacrodata_calendar/main.go @@ -0,0 +1,119 @@ +// Copyright (c) 2021-2026 The Indicator Authors. +// The source code is provided under GNU AGPLv3 License. +// https://github.com/cinar/indicator + +// Package main provides a self-contained example of fetching and filtering +// macroeconomic announcement events from the FXMacroData public release-calendar endpoint. +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strings" + "time" +) + +type calendarResponse struct { + Data []calendarEvent `json:"data"` + Error json.RawMessage `json:"error,omitempty"` + Detail json.RawMessage `json:"detail,omitempty"` +} + +type calendarEvent struct { + Name string `json:"name"` + Date string `json:"date"` + AnnouncementDatetimeUTC string `json:"announcement_datetime_utc"` + MarketTier int `json:"market_tier"` + TopTierForCurrency bool `json:"top_tier_for_currency"` +} + +func main() { + events, err := fetchCalendar("USD", "2026-07-01", "2026-07-20") + if err != nil { + panic(err) + } + + fmt.Println("Top-tier USD macro blackout dates:") + for _, event := range topTierEvents(events) { + fmt.Printf(" %s: %s\n", eventDate(event), event.Name) + } +} + +var apiHost = "https://fxmacrodata.com" + +func fetchCalendar(currency, startDate, endDate string) ([]calendarEvent, error) { + base, err := url.Parse(apiHost) + if err != nil || base.Hostname() == "" || base.User != nil || + (base.Scheme != "https" && base.Scheme != "http") || + strings.ContainsAny(apiHost, "?#") { + return nil, fmt.Errorf("calendar API host must be HTTP(S), with a host and no credentials, query or fragment") + } + endpoint := strings.TrimRight(base.String(), "/") + "/api/v1/calendar/" + url.PathEscape(currency) + params := url.Values{} + params.Set("start_date", startDate) + params.Set("end_date", endDate) + + client := http.Client{ + Timeout: 20 * time.Second, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + } + res, err := client.Get(endpoint + "?" + params.Encode()) + if err != nil { + return nil, fmt.Errorf("calendar request failed") + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("calendar request failed with HTTP %d", res.StatusCode) + } + + var payload calendarResponse + decoder := json.NewDecoder(res.Body) + if err := decoder.Decode(&payload); err != nil { + return nil, fmt.Errorf("calendar response could not be decoded") + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + return nil, fmt.Errorf("calendar response must contain one JSON value") + } + for _, field := range []json.RawMessage{payload.Error, payload.Detail} { + if len(field) > 0 && !bytes.Equal(bytes.TrimSpace(field), []byte("null")) { + return nil, fmt.Errorf("calendar response contains an error") + } + } + if payload.Data == nil { + return nil, fmt.Errorf("calendar response must contain a data list") + } + return payload.Data, nil +} + +func topTierEvents(events []calendarEvent) []calendarEvent { + filtered := make([]calendarEvent, 0) + for _, event := range events { + if event.TopTierForCurrency || event.MarketTier == 1 { + filtered = append(filtered, event) + } + } + sort.Slice(filtered, func(i, j int) bool { + return eventDate(filtered[i]) < eventDate(filtered[j]) + }) + return filtered +} + +func eventDate(event calendarEvent) string { + value := event.AnnouncementDatetimeUTC + if value == "" { + value = event.Date + } + if len(value) > 10 { + return value[:10] + } + return value +} diff --git a/examples/fxmacrodata_calendar/main_test.go b/examples/fxmacrodata_calendar/main_test.go new file mode 100644 index 0000000..56b6cb8 --- /dev/null +++ b/examples/fxmacrodata_calendar/main_test.go @@ -0,0 +1,171 @@ +// Copyright (c) 2021-2026 Onur Cinar. +// The source code is provided under GNU AGPLv3 License. +// https://github.com/cinar/indicator + +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestEventDate(t *testing.T) { + tests := []struct { + event calendarEvent + expected string + }{ + { + event: calendarEvent{ + AnnouncementDatetimeUTC: "2026-07-14T12:30:00Z", + }, + expected: "2026-07-14", + }, + { + event: calendarEvent{ + Date: "2026-07-15", + }, + expected: "2026-07-15", + }, + { + event: calendarEvent{ + Date: "2026", + }, + expected: "2026", + }, + } + + for _, test := range tests { + actual := eventDate(test.event) + if actual != test.expected { + t.Errorf("actual %s expected %s", actual, test.expected) + } + } +} + +func TestTopTierEvents(t *testing.T) { + events := []calendarEvent{ + { + Name: "Low Tier Event", + Date: "2026-07-15", + MarketTier: 3, + }, + { + Name: "High Tier Event 1", + Date: "2026-07-16", + MarketTier: 1, + }, + { + Name: "Top Tier Currency Event", + Date: "2026-07-14", + MarketTier: 2, + TopTierForCurrency: true, + }, + } + + filtered := topTierEvents(events) + + if len(filtered) != 2 { + t.Fatalf("expected 2 events, got %d", len(filtered)) + } + + // Verify sorting and filtering + if filtered[0].Name != "Top Tier Currency Event" { + t.Errorf("expected first event to be Top Tier Currency Event, got %s", filtered[0].Name) + } + if filtered[1].Name != "High Tier Event 1" { + t.Errorf("expected second event to be High Tier Event 1, got %s", filtered[1].Name) + } +} + +func TestFetchCalendar(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/calendar/USD" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if r.URL.Query().Get("start_date") != "2026-07-01" { + t.Errorf("unexpected start_date: %s", r.URL.Query().Get("start_date")) + } + if r.URL.Query().Get("end_date") != "2026-07-20" { + t.Errorf("unexpected end_date: %s", r.URL.Query().Get("end_date")) + } + + response := calendarResponse{ + Data: []calendarEvent{ + { + Name: "Core Inflation", + Date: "2026-07-14", + MarketTier: 1, + }, + }, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + oldHost := apiHost + apiHost = server.URL + defer func() { apiHost = oldHost }() + + events, err := fetchCalendar("USD", "2026-07-01", "2026-07-20") + if err != nil { + t.Fatalf("failed to fetch calendar: %v", err) + } + + if len(events) != 1 { + t.Fatalf("expected 1 event, got %d", len(events)) + } + + if events[0].Name != "Core Inflation" { + t.Errorf("expected Core Inflation, got %s", events[0].Name) + } +} + +func TestFetchCalendarError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + oldHost := apiHost + apiHost = server.URL + defer func() { apiHost = oldHost }() + + _, err := fetchCalendar("USD", "2026-07-01", "2026-07-20") + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestMainFunction(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response := calendarResponse{ + Data: []calendarEvent{ + { + Name: "Core Inflation", + Date: "2026-07-14", + MarketTier: 1, + }, + }, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + oldHost := apiHost + apiHost = server.URL + defer func() { apiHost = oldHost }() + + // Verify main runs without panic when API returns successful result + defer func() { + if r := recover(); r != nil { + t.Errorf("main panicked: %v", r) + } + }() + main() +} diff --git a/examples/fxmacrodata_calendar/request_validation_test.go b/examples/fxmacrodata_calendar/request_validation_test.go new file mode 100644 index 0000000..84a87cc --- /dev/null +++ b/examples/fxmacrodata_calendar/request_validation_test.go @@ -0,0 +1,148 @@ +// Copyright (c) 2021-2026 The Indicator Authors. +// The source code is provided under GNU AGPLv3 License. +// https://github.com/cinar/indicator + +package main + +import ( + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +func useCalendarHost(t *testing.T, host string) { + t.Helper() + previous := apiHost + apiHost = host + t.Cleanup(func() { apiHost = previous }) +} + +func TestCalendarCurrencyPathEscaping(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.EscapedPath() != "/api/v1/calendar/US%2FD%3Fextra=1%23part" || + r.URL.Query().Get("extra") != "" || r.URL.Query().Get("start_date") != "2026-07-01" || + r.URL.Query().Get("end_date") != "2026-07-20" { + t.Error("currency must remain one encoded path segment with the original date query") + } + _, _ = io.WriteString(w, `{"data":[]}`) + })) + defer server.Close() + useCalendarHost(t, server.URL) + if _, err := fetchCalendar("US/D?extra=1#part", "2026-07-01", "2026-07-20"); err != nil { + t.Fatal(err) + } +} + +func TestCalendarRejectsUnsafeAPIHost(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls.Add(1) + _, _ = io.WriteString(w, `{"data":[]}`) + })) + defer server.Close() + previous := apiHost + t.Cleanup(func() { apiHost = previous }) + const marker = "private-provider-marker" + for name, host := range map[string]string{ + "empty": "", + "non-HTTP": "ftp://127.0.0.1/v1", + "missing host": "https:///v1", + "credentials": strings.Replace(server.URL, "http://", "http://user:"+marker+"@", 1), + "query": server.URL + "?token=" + marker, + "fragment": server.URL + "#" + marker, + "control": server.URL + "\n", + "invalid escape": server.URL + "/%xx", + } { + t.Run(name, func(t *testing.T) { + apiHost = host + before := calls.Load() + _, err := fetchCalendar("USD", "2026-07-01", "2026-07-20") + if err == nil || err.Error() != "calendar API host must be HTTP(S), with a host and no credentials, query or fragment" || errors.Unwrap(err) != nil { + t.Error("expected a sanitized invalid API-host error") + } + if calls.Load() != before { + t.Error("invalid host reached the transport") + } + }) + } +} + +func TestCalendarRejectsRedirect(t *testing.T) { + var followed atomic.Int32 + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + followed.Add(1) + _, _ = io.WriteString(w, `{"data":[]}`) + })) + defer target.Close() + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL, http.StatusFound) + })) + defer origin.Close() + useCalendarHost(t, origin.URL) + _, err := fetchCalendar("USD", "2026-07-01", "2026-07-20") + if err == nil || err.Error() != "calendar request failed with HTTP 302" || followed.Load() != 0 { + t.Fatal("expected a numeric redirect error without following the redirect") + } +} + +func TestCalendarNetworkDiagnosticIsSanitized(t *testing.T) { + previous := http.DefaultTransport + http.DefaultTransport = deadlineTransport(func(_ *http.Request) (*http.Response, error) { + return nil, fmt.Errorf("private-provider-marker") + }) + t.Cleanup(func() { http.DefaultTransport = previous }) + _, err := fetchCalendar("USD", "2026-07-01", "2026-07-20") + if err == nil || err.Error() != "calendar request failed" || errors.Unwrap(err) != nil { + t.Fatal("expected a sanitized transport error without a provider cause") + } +} + +func TestCalendarStatusReasonIsSanitized(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + connection, buffer, err := w.(http.Hijacker).Hijack() + if err != nil { + t.Error(err) + return + } + defer connection.Close() + _, _ = buffer.WriteString("HTTP/1.1 500 private-provider-marker\r\nContent-Length: 0\r\n\r\n") + _ = buffer.Flush() + })) + defer server.Close() + useCalendarHost(t, server.URL) + _, err := fetchCalendar("USD", "2026-07-01", "2026-07-20") + if err == nil || err.Error() != "calendar request failed with HTTP 500" { + t.Fatal("expected numeric HTTP status without the provider's reason phrase") + } +} + +func TestCalendarDecodeDiagnosticIsSanitized(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `{"data":[{"market_tier":18446744073709551616}]}`) + })) + defer server.Close() + useCalendarHost(t, server.URL) + _, err := fetchCalendar("USD", "2026-07-01", "2026-07-20") + if err == nil || err.Error() != "calendar response could not be decoded" || errors.Unwrap(err) != nil { + t.Fatal("expected a sanitized JSON decoding error without the raw value or cause") + } +} + +func TestCalendarTrailingSlashHost(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/calendar/USD" { + t.Error("expected one slash between the configured base and endpoint") + } + _, _ = io.WriteString(w, `{"data":[]}`) + })) + defer server.Close() + useCalendarHost(t, server.URL+"/") + if _, err := fetchCalendar("USD", "2026-07-01", "2026-07-20"); err != nil { + t.Fatal(err) + } +} diff --git a/examples/fxmacrodata_calendar/response_validation_test.go b/examples/fxmacrodata_calendar/response_validation_test.go new file mode 100644 index 0000000..209babe --- /dev/null +++ b/examples/fxmacrodata_calendar/response_validation_test.go @@ -0,0 +1,99 @@ +// Copyright (c) 2021-2026 Onur Cinar. +// The source code is provided under GNU AGPLv3 License. +// https://github.com/cinar/indicator + +package main + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +type deadlineTransport func(*http.Request) (*http.Response, error) + +func (transport deadlineTransport) RoundTrip(request *http.Request) (*http.Response, error) { + return transport(request) +} + +func TestFetchCalendarHasBoundedRequestDeadline(t *testing.T) { + original := http.DefaultTransport + http.DefaultTransport = deadlineTransport(func(request *http.Request) (*http.Response, error) { + deadline, ok := request.Context().Deadline() + if !ok || time.Until(deadline) <= 0 || time.Until(deadline) > time.Minute { + t.Error("expected a positive request deadline of at most one minute") + } + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"data":[]}`)), Header: make(http.Header)}, nil + }) + t.Cleanup(func() { http.DefaultTransport = original }) + if _, err := fetchCalendar("USD", "2026-07-01", "2026-07-20"); err != nil { + t.Fatal(err) + } +} + +func calendarBody(t *testing.T, body string) ([]calendarEvent, error) { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(writer, body) + })) + defer server.Close() + original := apiHost + apiHost = server.URL + defer func() { apiHost = original }() + return fetchCalendar("USD", "2026-07-01", "2026-07-20") +} + +func TestFetchCalendarRejectsErrorWithData(t *testing.T) { + _, err := calendarBody(t, `{"error":"private-provider-marker","data":[]}`) + if err == nil || strings.Contains(err.Error(), "private-provider-marker") { + t.Fatal("expected a sanitized error for an error envelope") + } +} + +func TestFetchCalendarRejectsDetailWithData(t *testing.T) { + _, err := calendarBody(t, `{"detail":{"message":"private-provider-marker"},"data":[]}`) + if err == nil || strings.Contains(err.Error(), "private-provider-marker") { + t.Fatal("expected a sanitized error for a detail envelope") + } +} + +func TestFetchCalendarRejectsMissingData(t *testing.T) { + if _, err := calendarBody(t, `{}`); err == nil { + t.Fatal("expected an error for missing data") + } +} + +func TestFetchCalendarRejectsNullData(t *testing.T) { + if _, err := calendarBody(t, `{"data":null}`); err == nil { + t.Fatal("expected an error for null data") + } +} + +func TestFetchCalendarRejectsNullDocument(t *testing.T) { + if _, err := calendarBody(t, `null`); err == nil { + t.Fatal("expected an error for a null document") + } +} + +func TestFetchCalendarRejectsTrailingDocument(t *testing.T) { + if _, err := calendarBody(t, `{"data":[]} {"data":[]}`); err == nil { + t.Fatal("expected an error for a second JSON document") + } +} + +func TestFetchCalendarRejectsTrailingText(t *testing.T) { + if _, err := calendarBody(t, `{"data":[]} invalid`); err == nil { + t.Fatal("expected an error for trailing invalid text") + } +} + +func TestFetchCalendarPreservesGenuineEmptyData(t *testing.T) { + rows, err := calendarBody(t, `{"data":[]}`) + if err != nil || rows == nil || len(rows) != 0 { + t.Fatal("expected a genuine empty data list to succeed") + } +}