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
72 changes: 72 additions & 0 deletions ats_form_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
package main

import (
"encoding/json"
"net/http/httptest"
"strings"
"testing"
Expand Down Expand Up @@ -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)
}
})
}
}
91 changes: 71 additions & 20 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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+)`)

Expand Down Expand Up @@ -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 {
Expand All @@ -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"],
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down
4 changes: 2 additions & 2 deletions tproperties_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading