Skip to content

decodeObservationPayload silently drops numeric phenomenonTime; should reject symmetrically with resultTime #19

@Sam-Bolling

Description

@Sam-Bolling

Summary

decodeObservationPayload in internal/api/observation_handler.go reads phenomenonTime from the raw JSON map with raw["phenomenonTime"].(string). When the type assertion fails (e.g. client sent a numeric epoch), the field is silently dropped: no error returned, observation created with HTTP 201, phenomenonTime field discarded.

This is inconsistent with the sibling resultTime path, which catches the same wrong-type input via the late obs.ResultTime.IsZero() guard and returns 400 (see #3).

Surfaced during evaluation of #3.

Reproduction (HEAD, commit 4b994212)

curl -i -X POST "$BASE/datastreams/$DS/observations" \
  -H "Content-Type: application/json" \
  -d '{"resultTime":"2026-04-30T12:00:01Z","phenomenonTime":1773100000,"result":42}'
# → HTTP/2 201 — observation created
# GET shows phenomenonTime defaulted to resultTime (1773100000 was discarded)

Compare:

curl -i -X POST "$BASE/datastreams/$DS/observations" \
  -H "Content-Type: application/json" \
  -d '{"resultTime":1773100000,"result":42}'
# → HTTP/2 400 {"error":"resultTime is required"}

Same wrong-type input → opposite outcomes on sibling fields.

Code reference

// internal/api/observation_handler.go (around line 240)
if phenomenonTimeRaw, ok := raw["phenomenonTime"].(string); ok && phenomenonTimeRaw != "" {
    phenomenonTime, err := time.Parse(time.RFC3339, phenomenonTimeRaw)
    if err != nil {
        return nil, &decodeError{msg: "Invalid phenomenonTime format"}
    }
    obs.PhenomenonTime = &phenomenonTime
}
// ← no else; control falls through; no late "must be string" guard

Impact

  • Clients sending numeric/legacy phenomenonTime get an apparently-successful POST but the actual observation timestamp is lost.
  • Inconsistent behaviour across resultTime (rejected) and phenomenonTime (silently accepted then dropped) is the worst of both worlds — clients cannot infer one's behaviour from the other.

Suggested fix

Branch on type-assertion failure explicitly:

if v, present := raw["phenomenonTime"]; present && v != nil {
    s, ok := v.(string)
    if !ok {
        return nil, &decodeError{msg: "phenomenonTime must be an RFC 3339 string"}
    }
    if s != "" {
        t, err := time.Parse(time.RFC3339, s)
        if err != nil {
            return nil, &decodeError{msg: "Invalid phenomenonTime format: expected RFC 3339"}
        }
        obs.PhenomenonTime = &t
    }
}

Apply the same shape to resultTime (which simultaneously fixes the misleading error message in the separate UX follow-up).

Related

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions