From 63ee418c5e5c60679629143eb2cbfd7a50bef562 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esteban=20Zim=C3=A1nyi?= Date: Sat, 29 Aug 2026 23:36:24 +0200 Subject: [PATCH] Accept only the interpolations the standard names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interpolation a client may write is the set OGC API - Moving Features Part 1 names: a temporal geometry takes Discrete, Step, Linear, Quadratic or Cubic through `motionCurve`, and a temporal property value takes Discrete, Step, Linear or Regression through `temporalPrimitiveValue`. `Stepwise` is the older MF-JSON encoding extension's word for the step function, Part 1 does not name it, and the tier no longer answers to it. A name outside the set is refused where it is read rather than carried into MobilityDB to fail there, and the two refusals are different answers: a name the standard admits and MobilityDB does not carry — Quadratic, Cubic, Regression — is 501, because the request is understood and unserved, while a name the standard does not admit at all is 400. The status travels with the error, so a temporal property POST reports the one its body earns. --- ats_form_test.go | 72 +++++++++++++++++++++++++++++++++++ main.go | 91 +++++++++++++++++++++++++++++++++++---------- tproperties_test.go | 4 +- 3 files changed, 145 insertions(+), 22 deletions(-) diff --git a/ats_form_test.go b/ats_form_test.go index af1dea8..934a1be 100644 --- a/ats_form_test.go +++ b/ats_form_test.go @@ -11,6 +11,7 @@ package main import ( + "encoding/json" "net/http/httptest" "strings" "testing" @@ -66,3 +67,74 @@ func TestATSFormRefusedOnWrite(t *testing.T) { } }) } + +// The interpolation a client may write is the set the standard names, and a name +// outside it is refused rather than carried to MobilityDB to fail there. +// +// ⛔ THE TWO REFUSALS ARE DIFFERENT ANSWERS. A name the standard admits and this +// tier does not carry is 501, because the request is understood and unserved; a +// name the standard does not admit is 400, because it is not a request at all. +// `Stepwise` is the second kind: it is the older MF-JSON encoding extension's +// word for the step function and Part 1 does not name it. +func TestATSInterpolationIsTheStandardsSet(t *testing.T) { + for _, c := range []struct { + in string + mdb string + code int + }{ + {"Linear", "Linear", 0}, + {"Step", "Step", 0}, + {"Discrete", "Discrete", 0}, + {"Quadratic", "", 501}, + {"Cubic", "", 501}, + {"Regression", "", 501}, + {"Stepwise", "", 400}, + {"stepwise", "", 400}, + {"Bogus", "", 400}, + } { + t.Run(c.in, func(t *testing.T) { + got, err := mdbInterp(c.in) + if c.code == 0 { + if err != nil { + t.Fatalf("%q is a name the standard admits: %v", c.in, err) + } + if got != c.mdb { + t.Errorf("%q resolves to %q, want %q", c.in, got, c.mdb) + } + return + } + if err == nil { + t.Fatalf("%q resolves to %q, want a refusal", c.in, got) + } + if s := errStatus(err, 400); s != c.code { + t.Errorf("%q is refused with %d, want %d (%v)", c.in, s, c.code, err) + } + }) + } +} + +// The refusal reaches a client through the body it writes, not only through the +// resolver, so a temporal property naming an interpolation the standard does not +// carry answers 501 rather than a MobilityDB parse error. +func TestATSInterpolationRefusalReachesTheClient(t *testing.T) { + for _, c := range []struct { + in string + code int + }{{"Regression", 501}, {"Stepwise", 400}} { + t.Run(c.in, func(t *testing.T) { + body := `{"datetimes":["2026-01-01T00:00:00Z","2026-01-01T00:10:00Z"],` + + `"values":[1,2],"interpolation":"` + c.in + `"}` + var m map[string]any + if err := json.Unmarshal([]byte(body), &m); err != nil { + t.Fatal(err) + } + _, err := tPropMFJSON("MovingFloat", "Linear", m) + if err == nil { + t.Fatalf("%q is carried into the MF-JSON rather than refused", c.in) + } + if s := errStatus(err, 400); s != c.code { + t.Errorf("%q reaches the client as %d, want %d (%v)", c.in, s, c.code, err) + } + }) + } +} diff --git a/main.go b/main.go index ee49235..1b9c275 100644 --- a/main.go +++ b/main.go @@ -47,13 +47,55 @@ var ( // The step function is "Step" on both sides. OGC API - Moving Features Part 1 // names it so in each of the two places it constrains an interpolation — // `motionCurve` (Discrete/Step/Linear/Quadratic/Cubic, what a temporal geometry -// takes) and `temporalPrimitiveValue` (Discrete/Step/Linear/Regression) — and -// the word "Stepwise" appears nowhere in that standard. "Stepwise" belongs to -// the older MF-JSON encoding extension, and is accepted on input for a client -// that still writes it. -var ogc2mdbInterp = map[string]string{ - "Linear": "Linear", "Step": "Step", "Discrete": "Discrete", "Stepwise": "Step", +// takes) and `temporalPrimitiveValue` (Discrete/Step/Linear/Regression). +// +// ⛔ THE ADMITTED SET IS THE STANDARD'S, AND IT IS CLOSED. "Stepwise" is the +// older MF-JSON encoding extension's word for the step function and appears +// nowhere in Part 1, so it is not a name a conformant client writes and the tier +// does not answer to it. +var ogc2mdbInterp = map[string]string{"Linear": "Linear", "Step": "Step", "Discrete": "Discrete"} + +// ogcOnlyInterp is what the standard names and MobilityDB does not carry: the +// two curve fits a temporal geometry may ask for and the regression a temporal +// property may. A request for one is a request the tier understands and cannot +// serve, which is a different answer from one it does not understand. +var ogcOnlyInterp = map[string]bool{"Quadratic": true, "Cubic": true, "Regression": true} + +// statusErr is an error that names the status a client is owed for it, so a +// refusal keeps its meaning through the call that reports it. +type statusErr struct { + code int + msg string +} + +func (e statusErr) Error() string { return e.msg } + +// errStatus answers the status an error names, or def when it names none. +func errStatus(err error, def int) int { + var se statusErr + if errors.As(err, &se) { + return se.code + } + return def +} + +// mdbInterp answers the MobilityDB token for an OGC interpolation. A name the +// standard admits and this tier does not carry is 501; a name the standard does +// not admit at all is 400. The two are different answers and a client can act on +// the difference. +func mdbInterp(s string) (string, error) { + if m, ok := ogc2mdbInterp[s]; ok { + return m, nil + } + if ogcOnlyInterp[s] { + return "", statusErr{501, fmt.Sprintf("interpolation %q is not carried by MobilityDB; "+ + "this tier serves Discrete, Step and Linear", s)} + } + return "", statusErr{400, fmt.Sprintf("interpolation %q is not one the standard names; "+ + "a temporal geometry takes Discrete, Step, Linear, Quadratic or Cubic, "+ + "and a temporal property value takes Discrete, Step, Linear or Regression", s)} } + var epsgName = regexp.MustCompile(`"name":\s*"EPSG:(\d+)"`) var epsgURN = regexp.MustCompile(`EPSG:+(\d+)`) @@ -753,15 +795,12 @@ func orTrue(v any) bool { // a valueSequence array (a sequence set when it holds more than one segment). // The interpolation token is mapped from OGC to MobilityDB and defaults per type. func tPropMFJSON(mfType, defInterp string, body map[string]any) (string, error) { - mapInterp := func(v any) string { + mapInterp := func(v any) (string, error) { s, _ := v.(string) if s == "" { - return defInterp - } - if m, ok := ogc2mdbInterp[s]; ok { - return m + return defInterp, nil } - return s + return mdbInterp(s) } if vs, ok := body["valueSequence"].([]any); ok && len(vs) > 0 { if len(vs) == 1 { @@ -773,7 +812,10 @@ func tPropMFJSON(mfType, defInterp string, body map[string]any) (string, error) for _, s := range vs { m, _ := s.(map[string]any) if interp == "" { - interp = mapInterp(m["interpolation"]) + var err error + if interp, err = mapInterp(m["interpolation"]); err != nil { + return "", err + } } seqs = append(seqs, map[string]any{ "values": m["values"], "datetimes": m["datetimes"], @@ -786,9 +828,13 @@ func tPropMFJSON(mfType, defInterp string, body map[string]any) (string, error) if body["datetimes"] == nil || body["values"] == nil { return "", errors.New("temporal property requires datetimes and values (or a valueSequence)") } + interp, err := mapInterp(body["interpolation"]) + if err != nil { + return "", err + } b, _ := json.Marshal(map[string]any{ "type": mfType, "datetimes": body["datetimes"], "values": body["values"], - "interpolation": mapInterp(body["interpolation"]), + "interpolation": interp, "lower_inc": orTrue(body["lower_inc"]), "upper_inc": orTrue(body["upper_inc"]), }) return string(b), nil @@ -1383,9 +1429,12 @@ func postItem(w http.ResponseWriter, r *http.Request) { return } if interp, ok := feat.TG["interpolation"].(string); ok { - if m, ok := ogc2mdbInterp[interp]; ok { - feat.TG["interpolation"] = m + m, err := mdbInterp(interp) + if err != nil { + httpErr(w, errStatus(err, 400), err.Error()) + return } + feat.TG["interpolation"] = m } tgBytes, _ := json.Marshal(feat.TG) // default to the collection CRS; an explicit feature crs overrides it @@ -1426,9 +1475,11 @@ func featureTG(r *http.Request) (tgText string, props map[string]any, err error) return "", nil, errors.New("missing temporalGeometry") } if interp, ok := tg["interpolation"].(string); ok { - if m, ok := ogc2mdbInterp[interp]; ok { - tg["interpolation"] = m + m, err := mdbInterp(interp) + if err != nil { + return "", nil, err } + tg["interpolation"] = m } if p, ok := raw["properties"].(map[string]any); ok { props = p @@ -1632,7 +1683,7 @@ func postTProperties(w http.ResponseWriter, r *http.Request) { uom := propForm(p) mfjson, perr := tPropMFJSON(tt.mf, tt.defInterp, p) if perr != nil { - httpErr(w, 400, perr.Error()) + httpErr(w, errStatus(perr, 400), perr.Error()) return } if _, e := tx.Exec(r.Context(), @@ -1689,7 +1740,7 @@ func postTPropertyValues(w http.ResponseWriter, r *http.Request) { } mfjson, perr := tPropMFJSON(tt.mf, tt.defInterp, body) if perr != nil { - httpErr(w, 400, perr.Error()) + httpErr(w, errStatus(perr, 400), perr.Error()) return } ct, e := db.Exec(r.Context(), diff --git a/tproperties_test.go b/tproperties_test.go index 7e97fe5..b0616b5 100644 --- a/tproperties_test.go +++ b/tproperties_test.go @@ -47,9 +47,9 @@ func TestTPropMFJSON(t *testing.T) { return m } - // flat form, OGC "Stepwise" maps to MobilityDB "Step" + // flat form, the standard's own token for the step function out, err := tPropMFJSON("MovingFloat", "Linear", - parse(`{"datetimes":["2026-01-01T00:00:00Z"],"values":[1],"interpolation":"Stepwise"}`)) + parse(`{"datetimes":["2026-01-01T00:00:00Z"],"values":[1],"interpolation":"Step"}`)) if err != nil { t.Fatal(err) }