Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 43 additions & 11 deletions internal/restapi/health_handler.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,47 @@
package restapi

import (
"context"
"database/sql"
"encoding/json"
"errors"
"net/http"
"time"

"maglev.onebusaway.org/internal/logging"
)

// DataFreshness represents the last update timestamps for GTFS data.
// DataFreshness represents the last update timestamps for GTFS data and
// which static bundle is loaded.
type DataFreshness struct {
StaticGtfsLastUpdated *time.Time `json:"staticGtfsLastUpdated,omitempty"`
RealtimeFeeds map[string]time.Time `json:"realtimeFeeds,omitempty"`
StaticGtfsLastUpdated *time.Time `json:"staticGtfsLastUpdated,omitempty"`
// StaticGtfsHash is the hex sha256 of the raw static GTFS zip, so an
// external service can tell whether it validates against the same bundle.
StaticGtfsHash string `json:"staticGtfsHash,omitempty"`
RealtimeFeeds map[string]time.Time `json:"realtimeFeeds,omitempty"`
}

// dataFreshness reports when the static and realtime data were last updated
// and which static bundle is loaded. A database with no import yet is not an
// error: the static fields are left empty. Any other query failure is
// returned, so callers don't report a missing bundle when the read failed.
func (api *RestAPI) dataFreshness(ctx context.Context) (DataFreshness, error) {
freshness := DataFreshness{RealtimeFeeds: api.GtfsManager.GetFeedUpdateTimes()}

metadata, err := api.GtfsManager.GtfsDB.Queries.GetImportMetadata(ctx)
if errors.Is(err, sql.ErrNoRows) {
return freshness, nil
}
if err != nil {
return DataFreshness{}, err
}

if metadata.ImportTime != 0 {
importTime := time.Unix(0, metadata.ImportTime).UTC()
freshness.StaticGtfsLastUpdated = &importTime
}
freshness.StaticGtfsHash = metadata.FileHash
return freshness, nil
}

// HealthResponse represents the JSON response from the health endpoint.
Expand Down Expand Up @@ -64,15 +94,17 @@ func (api *RestAPI) healthHandler(w http.ResponseWriter, r *http.Request) {
// Fetch data freshness from the manager only if verbose=true is passed
var freshness *DataFreshness
if r.URL.Query().Get("verbose") == "true" {
t := api.GtfsManager.GetStaticLastUpdated(r.Context())
var staticTime *time.Time
if !t.IsZero() {
staticTime = &t
}
freshness = &DataFreshness{
StaticGtfsLastUpdated: staticTime,
RealtimeFeeds: api.GtfsManager.GetFeedUpdateTimes(),
f, err := api.dataFreshness(r.Context())
if err != nil {
logging.LogError(reqLogger, "reading data freshness failed", err)
w.WriteHeader(http.StatusServiceUnavailable)
_ = json.NewEncoder(w).Encode(HealthResponse{
Status: "unavailable",
Detail: "reading data freshness failed",
})
return
}
freshness = &f
}

// All checks passed
Expand Down
26 changes: 26 additions & 0 deletions internal/restapi/health_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,3 +195,29 @@ func TestHealthHandlerVerboseMode(t *testing.T) {
assert.NotNil(t, healthRespVerbose.DataFreshness.StaticGtfsLastUpdated)
assert.Contains(t, healthRespVerbose.DataFreshness.RealtimeFeeds, "feed-1")
}

func TestHealthHandler_VerboseIncludesHash(t *testing.T) {
api := createTestApi(t)

resp, health := callAPIHandler[HealthResponse](t, api, "/healthz?verbose=true")

assert.Equal(t, http.StatusOK, resp.StatusCode)
require.NotNil(t, health.DataFreshness)
assert.Equal(t, rabaZipSha256(t), health.DataFreshness.StaticGtfsHash)
}

func TestHealthHandler_VerboseImportMetadataQueryFails(t *testing.T) {
manager := newTestManagerWithoutImportMetadata(t)
manager.MarkReady()
api := NewRestAPI(&app.Application{GtfsManager: manager})

req := httptest.NewRequest(http.MethodGet, "/healthz?verbose=true", nil)
rr := httptest.NewRecorder()
api.healthHandler(rr, req)

assert.Equal(t, http.StatusServiceUnavailable, rr.Code)
var health HealthResponse
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &health))
assert.Equal(t, "unavailable", health.Status)
assert.Nil(t, health.DataFreshness)
}
20 changes: 11 additions & 9 deletions internal/restapi/metadata_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"encoding/json"
"net/http"
"time"

"maglev.onebusaway.org/internal/logging"
)

// metadataHandler returns system metadata including data freshness indicators.
Expand All @@ -19,15 +21,15 @@ func (api *RestAPI) metadataHandler(w http.ResponseWriter, r *http.Request) {
return
}

t := api.GtfsManager.GetStaticLastUpdated(r.Context())
var staticTime *time.Time
if !t.IsZero() {
staticTime = &t
}

response := DataFreshness{
StaticGtfsLastUpdated: staticTime,
RealtimeFeeds: api.GtfsManager.GetFeedUpdateTimes(),
response, err := api.dataFreshness(r.Context())
if err != nil {
logging.LogError(logging.ForComponent(r.Context(), "http_server"), "reading data freshness failed", err)
w.WriteHeader(http.StatusServiceUnavailable)
_ = json.NewEncoder(w).Encode(map[string]string{
"status": "unavailable",
"detail": "reading data freshness failed",
})
return
}

// Ensure the map isn't nil for JSON serialization
Expand Down
72 changes: 72 additions & 0 deletions internal/restapi/metadata_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,20 @@ package restapi

import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"maglev.onebusaway.org/internal/app"
"maglev.onebusaway.org/internal/gtfs"
)

func TestMetadataHandler_NilManager(t *testing.T) {
Expand Down Expand Up @@ -81,3 +88,68 @@ func TestMetadataHandler(t *testing.T) {
t.Errorf("Expected StaticGtfsLastUpdated to match set time")
}
}

// rabaZipSha256 hashes the static fixture the way an external consumer such
// as Watchdog would, independently of what Maglev stored.
func rabaZipSha256(t *testing.T) string {
t.Helper()
zipBytes, err := os.ReadFile(filepath.Join("../../testdata", "raba.zip"))
require.NoError(t, err)
sum := sha256.Sum256(zipBytes)
return hex.EncodeToString(sum[:])
}

func TestMetadataHandler_StaticGtfsHash(t *testing.T) {
api := createTestApi(t)

resp, body := callAPIHandler[map[string]any](t, api, "/api/v2/metadata.json?key=TEST")

assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, rabaZipSha256(t), body["staticGtfsHash"])
}

func TestMetadataHandler_NoImportOmitsHash(t *testing.T) {
api := &RestAPI{
Application: &app.Application{
GtfsManager: newTestManagerNoData(t),
},
}

req := httptest.NewRequest(http.MethodGet, "/api/v2/metadata.json", nil)
rr := httptest.NewRecorder()
api.metadataHandler(rr, req)

assert.Equal(t, http.StatusOK, rr.Code)
var body map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &body))
assert.NotContains(t, body, "staticGtfsHash")
}

// newTestManagerWithoutImportMetadata returns a manager whose database is
// reachable but fails import metadata reads, unlike an empty database.
func newTestManagerWithoutImportMetadata(t *testing.T) *gtfs.Manager {
t.Helper()
manager := newTestManagerNoData(t)
_, err := manager.GtfsDB.DB.Exec("DROP TABLE import_metadata")
require.NoError(t, err)
return manager
}

func TestMetadataHandler_ImportMetadataQueryFails(t *testing.T) {
manager := newTestManagerWithoutImportMetadata(t)
api := &RestAPI{
Application: &app.Application{
GtfsManager: manager,
},
}

req := httptest.NewRequest(http.MethodGet, "/api/v2/metadata.json", nil)
rr := httptest.NewRecorder()
api.metadataHandler(rr, req)

assert.Equal(t, http.StatusServiceUnavailable, rr.Code)
var body map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &body))
assert.Equal(t, "unavailable", body["status"])
assert.NotContains(t, body, "staticGtfsHash")
}
Loading