diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index b7bbe04..e340997 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -102,3 +102,38 @@ jobs: exit 1 fi echo "$groups live conformance test group(s) ran against MobilityDB" + + # ⛔ A SAMPLE THAT HAS DRIFTED IS WORSE THAN NO SAMPLE: it states what the + # service used to answer, and a reader has no way to tell. Re-emitting here + # and diffing against what is committed is what keeps samples/ the current + # answer rather than a snapshot somebody forgot to refresh. + # + # The TemporalProperties document carries the time it was written, which the + # standard defines it to carry, so that one line is normalised on both sides. + - name: The committed samples are what the service writes + run: | + set -o pipefail + go run . -emit samples.emitted + norm() { sed -E 's/"timeStamp": "[^"]*"/"timeStamp": ""/' "$1"; } + rc=0 + for f in samples/*; do + b=$(basename "$f") + if [ ! -f "samples.emitted/$b" ]; then + echo "::error::samples/$b is no longer a resource the service serves"; rc=1; continue + fi + if ! diff -q <(norm "$f") <(norm "samples.emitted/$b") >/dev/null; then + echo "::error::samples/$b is not what the service writes; re-run: go run . -emit samples" + diff -u <(norm "$f") <(norm "samples.emitted/$b") | head -40 + rc=1 + fi + done + for f in samples.emitted/*; do + b=$(basename "$f") + if [ ! -f "samples/$b" ]; then + echo "::error::the service serves a resource samples/ does not carry: $b"; rc=1 + fi + done + if [ "$rc" -eq 0 ]; then + echo "$(ls samples/*.json | wc -l) sample documents are what the service writes" + fi + exit $rc diff --git a/.gitignore b/.gitignore index 9b526d1..610d6b9 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ MobilityAPI-go mfapi mfapi-meos live.log + +# the sample set the CI freshness check re-emits for comparison +samples.emitted/ diff --git a/ats_schema_test.go b/ats_schema_test.go index d94c721..cdce40c 100644 --- a/ats_schema_test.go +++ b/ats_schema_test.go @@ -396,3 +396,72 @@ func TestATSSchemaBundleMatchesOGC(t *testing.T) { ogcBundlePath, ogcBundleURL, len(vendored), len(published)) } } + +// /conf/movingfeatures/tproperty-get-success — the `type` a TemporalProperty +// document names is one the standard defines. +// +// ⛔ THE ADMITTED SET IS READ OUT OF THE VENDORED DOCUMENT, never written here: a +// constant chosen in this file would agree with whatever the tier does and assert +// nothing. The schema states the enum once, at +// components/schemas/temporalProperty/properties/type. +// +// ⛔ THE WHOLE DOCUMENT CANNOT BE VALIDATED THE WAY ITS SIBLINGS ARE, and the +// reason is the standard's, not the tier's: `temporalPrimitiveValue` declares +// `datetimes` an array of `minItems: 2` beside `values` as a single scalar +// (`oneOf` number/string/boolean), so no document carrying a value per instant +// satisfies it; and its `interpolation` enum reads Discrete/Step/Linear/Regression +// where the temporal-geometry half of the same document names Stepwise. This +// asserts the cell that has one authority, which is the type token. +func TestATSSchemaTemporalPropertyType(t *testing.T) { + f, err := os.Open(ogcBundlePath) + if err != nil { + t.Fatal(err) + } + defer f.Close() + var doc struct { + Components struct { + Schemas struct { + TemporalProperty struct { + Properties struct { + Type struct { + Enum []string `json:"enum"` + } `json:"type"` + } `json:"properties"` + } `json:"temporalProperty"` + } `json:"schemas"` + } `json:"components"` + } + if err := json.NewDecoder(f).Decode(&doc); err != nil { + t.Fatalf("reading %s: %v", ogcBundlePath, err) + } + admitted := doc.Components.Schemas.TemporalProperty.Properties.Type.Enum + if len(admitted) == 0 { + t.Fatal("the vendored document declares no type enum, so this test would assert nothing") + } + in := func(s string) bool { + for _, a := range admitted { + if a == s { + return true + } + } + return false + } + // Every spelling the tier accepts, so a token is checked per stored type rather + // than for the one type a single case would happen to cover. + for _, stored := range []string{ + "TReal", "tfloat", "measure", "number", + "TInteger", "tint", "integer", "int", + "TText", "tstring", "text", "string", + "TBoolean", "tbool", "boolean", "bool", + } { + tt, ok := tPropType(stored) + if !ok { + t.Errorf("the tier does not resolve the stored type %q", stored) + continue + } + if !in(tt.ogc) { + t.Errorf("a %q property is written as type %q, which the standard does not define; it admits %v", + stored, tt.ogc, admitted) + } + } +} diff --git a/main.go b/main.go index e3d9aa6..09e4509 100644 --- a/main.go +++ b/main.go @@ -17,6 +17,7 @@ import ( "context" "encoding/json" "errors" + "flag" "fmt" "log" "net/http" @@ -71,6 +72,9 @@ func envInt(k string, def int) int { } func main() { + emit := flag.String("emit", "", "write one sample document per resource into this directory, then exit") + flag.Parse() + dsn := os.Getenv("MFAPI_DSN") if dsn == "" { dsn = "postgres:///mfapi_demo?host=/tmp&port=5432&user=esteban" @@ -85,6 +89,17 @@ func main() { log.Fatal("db ping: ", err) } + // The samples are written by the service, through the routing table it + // serves, so they are what the code answers rather than an illustration of + // it. See samples.go. + if *emit != "" { + if err := emitSamples(*emit); err != nil { + log.Fatal("emitting samples: ", err) + } + log.Printf("sample documents written to %s", *emit) + return + } + if broker := os.Getenv("MFAPI_MQTT_BROKER"); broker != "" { if _, err := startMQTTIngest(broker, "mfapi-"+strconv.Itoa(os.Getpid())); err != nil { log.Printf("MQTT ingestion disabled: %v", err) @@ -704,11 +719,11 @@ func tPropType(t string) (tType, bool) { case "", "treal", "tfloat", "measure", "real", "float", "double", "number": return tType{"MovingFloat", "vfloat", "tfloat", "TReal", "Linear"}, true case "tint", "tinteger", "integer", "int": - return tType{"MovingInteger", "vint", "tint", "TInt", "Step"}, true + return tType{"MovingInteger", "vint", "tint", "TInteger", "Step"}, true case "ttext", "tstring", "text", "string": return tType{"MovingText", "vtext", "ttext", "TText", "Discrete"}, true case "tbool", "tboolean", "boolean", "bool": - return tType{"MovingBoolean", "vbool", "tbool", "TBool", "Step"}, true + return tType{"MovingBoolean", "vbool", "tbool", "TBoolean", "Step"}, true } return tType{}, false } @@ -1138,7 +1153,7 @@ func apiDoc(w http.ResponseWriter, r *http.Request) { "/collections/{cid}/items/{fid}/tproperties": map[string]any{ "get": withParams(op("Stored temporal properties of a feature"), limitParam, datetimeParam, subTemporalValueParam), - "post": op("Add one or more temporal properties (TReal | TInt | TText | TBool) to a feature"), + "post": op("Add one or more temporal properties (TReal | TInteger | TText | TBoolean) to a feature"), }, "/collections/{cid}/items/{fid}/tproperties/{pname}": map[string]any{ "get": withParams(op("A stored temporal property as an OGC temporalProperty"), diff --git a/samples.go b/samples.go new file mode 100644 index 0000000..fe24c0d --- /dev/null +++ b/samples.go @@ -0,0 +1,205 @@ +// The sample documents: one per resource the tier serves, written by the tier +// itself. +// +// A sample assembled by hand states what its author believed the service +// answers. These state what it answers, because every one is the body of a real +// request through the service's own routing table. Regenerating rewrites the +// whole set, so a sample that has drifted from the code cannot survive one. +// +// The emitter names no collection, feature or property of its own. It reads the +// collections the service lists and takes the first, then that collection's +// features and takes the first, then that feature's temporal properties — the +// walk a client makes through the documents' own contents. So it answers for +// whatever database it is pointed at, and against +// tutorial/setup/load_conformance.sql it writes the conformance sample set. +// +// MFAPI_DSN=... mfapi -emit samples +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" +) + +// sample is one resource of the tier: the request that reads it, the OGC +// resource the answer is an instance of, and what the tier answered. +type sample struct { + file string // basename the document is written under + path string // the request the tier answers + what string // the resource this document is an instance of + code int + body []byte +} + +// firstMember reads the first element of a named array out of a document and +// returns one of its fields as text. It is how the emitter finds a collection, +// a feature and a temporal property without naming any of them. +func firstMember(body []byte, array, field string) (string, error) { + var doc map[string]any + if err := json.Unmarshal(body, &doc); err != nil { + return "", fmt.Errorf("the %s document is not JSON: %w", array, err) + } + members, ok := doc[array].([]any) + if !ok || len(members) == 0 { + return "", fmt.Errorf("the document carries no %s to sample", array) + } + m, ok := members[0].(map[string]any) + if !ok { + return "", fmt.Errorf("the first member of %s is not an object", array) + } + switch v := m[field].(type) { + case string: + return v, nil + case float64: + return strconv.FormatFloat(v, 'f', -1, 64), nil + case nil: + return "", fmt.Errorf("the first member of %s carries no %s", array, field) + default: + return fmt.Sprint(v), nil + } +} + +// emitSamples writes one document per resource into dir, plus an index naming +// each one and the request that produced it. +func emitSamples(dir string) error { + mux := newMux() + read := func(path string) (int, []byte) { + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) + return rec.Code, rec.Body.Bytes() + } + // A resource the walk needs in order to reach the next one has to answer, or + // the set below it would be silently missing rather than wrong. + require := func(path string) ([]byte, error) { + code, body := read(path) + if code != http.StatusOK { + return nil, fmt.Errorf("GET %s answered %d, so the walk cannot continue: %s", + path, code, strings.TrimSpace(string(body))) + } + return body, nil + } + + cols, err := require("/collections") + if err != nil { + return err + } + cid, err := firstMember(cols, "collections", "id") + if err != nil { + return err + } + collection := "/collections/" + cid + + items, err := require(collection + "/items") + if err != nil { + return err + } + fid, err := firstMember(items, "features", "id") + if err != nil { + return err + } + feature := collection + "/items/" + fid + + props, err := require(feature + "/tproperties") + if err != nil { + return err + } + pname, err := firstMember(props, "temporalProperties", "name") + if err != nil { + return err + } + + // A feature's temporal primitive geometries are addressed by their 1-based + // position, which is what sequenceN takes, so the first one is 1. + const firstSequence = "1" + tgseq := feature + "/tgsequence/" + firstSequence + + samples := []sample{ + {file: "landing-page", path: "/", what: "the landing page"}, + {file: "api-definition", path: "/api", what: "the API definition"}, + {file: "conformance-declaration", path: "/conformance", what: "the conformance declaration"}, + {file: "collections", path: "/collections", what: "the Collections document"}, + {file: "collection", path: collection, what: "a Collection document"}, + {file: "moving-feature-collection", path: collection + "/items", what: "a MovingFeatureCollection document"}, + {file: "moving-feature", path: feature, what: "a MovingFeature document"}, + {file: "temporal-geometry-sequence", path: feature + "/tgsequence", what: "a TemporalGeometrySequence document"}, + {file: "temporal-geometry-distance", path: tgseq + "/distance", what: "the distance a temporal primitive geometry covers"}, + {file: "temporal-geometry-velocity", path: tgseq + "/velocity", what: "the velocity along a temporal primitive geometry"}, + {file: "temporal-geometry-acceleration", path: tgseq + "/acceleration", what: "the acceleration along a temporal primitive geometry"}, + {file: "temporal-properties", path: feature + "/tproperties", what: "a TemporalProperties document"}, + {file: "temporal-property", path: feature + "/tproperties/" + pname, what: "a TemporalProperty document"}, + } + + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + for i := range samples { + s := &samples[i] + s.code, s.body = read(s.path) + name, out := s.file+".json", &bytes.Buffer{} + if json.Valid(s.body) { + if err := json.Indent(out, s.body, "", " "); err != nil { + return fmt.Errorf("indenting %s: %w", s.path, err) + } + out.WriteByte('\n') + } else { + // A response the service does not write as JSON is kept verbatim: the + // sample is the answer, not a rendering of it. + name = s.file + ".txt" + out.Write(s.body) + } + if err := os.WriteFile(filepath.Join(dir, name), out.Bytes(), 0o644); err != nil { + return err + } + } + return writeSampleIndex(dir, cid, fid, pname, samples) +} + +// writeSampleIndex writes the README naming every sample, the request that +// produced it and the status the tier answered with. +func writeSampleIndex(dir, cid, fid, pname string, samples []sample) error { + var b strings.Builder + b.WriteString(`# Sample documents + +Every file here is the body of one request through the service's own routing +table, written by the service itself: + + MFAPI_DSN=... mfapi -emit samples + +so a sample states what the code answers rather than what its author believed +it answers. Regenerating rewrites the whole set. + +These were read from the conformance fixture +(` + "`tutorial/setup/load_conformance.sql`" + `), whose collection is ` + "`" + cid + "`" + `, whose +first feature is ` + "`" + fid + "`" + ` and whose first temporal property is ` + "`" + pname + "`" + `. Pointed at +another database the emitter names that database's own resources instead: it +takes the first collection the service lists, that collection's first feature +and that feature's first temporal property. + +A status other than 200 is a sample too. The tier answers 501 for an +acceleration under linear interpolation, because a velocity that is constant on +every segment has no acceleration the standard's motion model can carry, and +saying so is the honest answer rather than a zero. + +⛔ The ` + "`timeStamp`" + ` of the TemporalProperties document is the moment the +document was written, which the standard defines it to be, so that one line +differs between two regenerations of an unchanged service. + +| file | status | request | resource | +|---|---|---|---| +`) + for _, s := range samples { + name := s.file + ".json" + if !json.Valid(s.body) { + name = s.file + ".txt" + } + fmt.Fprintf(&b, "| [`%s`](%s) | %d | `GET %s` | %s |\n", name, name, s.code, s.path, s.what) + } + return os.WriteFile(filepath.Join(dir, "README.md"), []byte(b.String()), 0o644) +} diff --git a/samples/README.md b/samples/README.md new file mode 100644 index 0000000..f7a276c --- /dev/null +++ b/samples/README.md @@ -0,0 +1,41 @@ +# Sample documents + +Every file here is the body of one request through the service's own routing +table, written by the service itself: + + MFAPI_DSN=... mfapi -emit samples + +so a sample states what the code answers rather than what its author believed +it answers. Regenerating rewrites the whole set. + +These were read from the conformance fixture +(`tutorial/setup/load_conformance.sql`), whose collection is `conformance`, whose +first feature is `1` and whose first temporal property is `anchored`. Pointed at +another database the emitter names that database's own resources instead: it +takes the first collection the service lists, that collection's first feature +and that feature's first temporal property. + +A status other than 200 is a sample too. The tier answers 501 for an +acceleration under linear interpolation, because a velocity that is constant on +every segment has no acceleration the standard's motion model can carry, and +saying so is the honest answer rather than a zero. + +⛔ The `timeStamp` of the TemporalProperties document is the moment the +document was written, which the standard defines it to be, so that one line +differs between two regenerations of an unchanged service. + +| file | status | request | resource | +|---|---|---|---| +| [`landing-page.json`](landing-page.json) | 200 | `GET /` | the landing page | +| [`api-definition.json`](api-definition.json) | 200 | `GET /api` | the API definition | +| [`conformance-declaration.json`](conformance-declaration.json) | 200 | `GET /conformance` | the conformance declaration | +| [`collections.json`](collections.json) | 200 | `GET /collections` | the Collections document | +| [`collection.json`](collection.json) | 200 | `GET /collections/conformance` | a Collection document | +| [`moving-feature-collection.json`](moving-feature-collection.json) | 200 | `GET /collections/conformance/items` | a MovingFeatureCollection document | +| [`moving-feature.json`](moving-feature.json) | 200 | `GET /collections/conformance/items/1` | a MovingFeature document | +| [`temporal-geometry-sequence.json`](temporal-geometry-sequence.json) | 200 | `GET /collections/conformance/items/1/tgsequence` | a TemporalGeometrySequence document | +| [`temporal-geometry-distance.json`](temporal-geometry-distance.json) | 200 | `GET /collections/conformance/items/1/tgsequence/1/distance` | the distance a temporal primitive geometry covers | +| [`temporal-geometry-velocity.json`](temporal-geometry-velocity.json) | 200 | `GET /collections/conformance/items/1/tgsequence/1/velocity` | the velocity along a temporal primitive geometry | +| [`temporal-geometry-acceleration.json`](temporal-geometry-acceleration.json) | 501 | `GET /collections/conformance/items/1/tgsequence/1/acceleration` | the acceleration along a temporal primitive geometry | +| [`temporal-properties.json`](temporal-properties.json) | 200 | `GET /collections/conformance/items/1/tproperties` | a TemporalProperties document | +| [`temporal-property.json`](temporal-property.json) | 200 | `GET /collections/conformance/items/1/tproperties/anchored` | a TemporalProperty document | diff --git a/samples/api-definition.json b/samples/api-definition.json new file mode 100644 index 0000000..9194e91 --- /dev/null +++ b/samples/api-definition.json @@ -0,0 +1,563 @@ +{ + "info": { + "description": "OGC API – Moving Features over MobilityDB", + "title": "MobilityAPI-go", + "version": "1.0.0" + }, + "openapi": "3.0.3", + "paths": { + "/": { + "get": { + "responses": { + "200": { + "description": "OK" + } + }, + "summary": "Landing page" + } + }, + "/api": { + "get": { + "responses": { + "200": { + "description": "OK" + } + }, + "summary": "API definition" + } + }, + "/collections": { + "get": { + "parameters": [ + { + "description": "The optional limit parameter limits the number of items presented in the response document.", + "explode": false, + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 10000, + "minimum": 1, + "type": "integer" + }, + "style": "form" + } + ], + "responses": { + "200": { + "description": "OK" + } + }, + "summary": "Moving feature collections" + }, + "post": { + "responses": { + "200": { + "description": "OK" + } + }, + "summary": "Register a new collection" + } + }, + "/collections/{cid}": { + "delete": { + "responses": { + "200": { + "description": "OK" + } + }, + "summary": "Delete a collection" + }, + "get": { + "responses": { + "200": { + "description": "OK" + } + }, + "summary": "Collection metadata" + }, + "put": { + "responses": { + "200": { + "description": "OK" + } + }, + "summary": "Replace collection metadata" + } + }, + "/collections/{cid}/bulk": { + "post": { + "responses": { + "200": { + "description": "OK" + } + }, + "summary": "Bulk ingest (extension): a batch of (vehicleId, position, time) observations as GeoJSON Points or GeoParquet, optionally gzip/deflate/br/zstd-compressed; each is appended as one instant" + } + }, + "/collections/{cid}/export": { + "get": { + "responses": { + "200": { + "description": "OK" + } + }, + "summary": "Bulk lakehouse export: NDJSON, or ?format=parquet (WKB + bbox/time sidecar)" + } + }, + "/collections/{cid}/items": { + "get": { + "parameters": [ + { + "description": "The optional limit parameter limits the number of items presented in the response document.", + "explode": false, + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 10000, + "minimum": 1, + "type": "integer" + }, + "style": "form" + }, + { + "description": "Only features whose geometry intersects the bounding box are selected.", + "explode": false, + "in": "query", + "name": "bbox", + "required": false, + "schema": { + "items": { + "type": "number" + }, + "maxItems": 6, + "minItems": 4, + "type": "array" + }, + "style": "form" + }, + { + "description": "Either a date-time or an interval. Only features that have a temporal geometry or temporal property that intersects the value are selected.", + "explode": false, + "in": "query", + "name": "datetime", + "required": false, + "schema": { + "type": "string" + }, + "style": "form" + }, + { + "description": "Only a subsequence of the temporal geometry clipped to the datetime interval is returned. The datetime parameter is then a bounded interval and the leaf parameter is not used.", + "explode": false, + "in": "query", + "name": "subTrajectory", + "required": false, + "schema": { + "default": false, + "type": "boolean" + }, + "style": "form" + } + ], + "responses": { + "200": { + "description": "OK" + } + }, + "summary": "Moving features (streamed, keyset-paged; bbox/datetime/subtrajectory filters)" + }, + "post": { + "responses": { + "200": { + "description": "OK" + } + }, + "summary": "Insert a moving feature" + } + }, + "/collections/{cid}/items/{fid}": { + "delete": { + "responses": { + "200": { + "description": "OK" + } + }, + "summary": "Delete a moving feature" + }, + "get": { + "responses": { + "200": { + "description": "OK" + } + }, + "summary": "A moving feature as a Feature" + }, + "put": { + "responses": { + "200": { + "description": "OK" + } + }, + "summary": "Replace a moving feature" + } + }, + "/collections/{cid}/items/{fid}/tgsequence": { + "get": { + "parameters": [ + { + "description": "The optional limit parameter limits the number of items presented in the response document.", + "explode": false, + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 10000, + "minimum": 1, + "type": "integer" + }, + "style": "form" + }, + { + "description": "Only features whose geometry intersects the bounding box are selected.", + "explode": false, + "in": "query", + "name": "bbox", + "required": false, + "schema": { + "items": { + "type": "number" + }, + "maxItems": 6, + "minItems": 4, + "type": "array" + }, + "style": "form" + }, + { + "description": "Either a date-time or an interval. Only features that have a temporal geometry or temporal property that intersects the value are selected.", + "explode": false, + "in": "query", + "name": "datetime", + "required": false, + "schema": { + "type": "string" + }, + "style": "form" + }, + { + "description": "Only features with a temporal geometry or temporal property that intersect the given date-times are selected. The date-times are given as a comma-separated list.", + "explode": false, + "in": "query", + "name": "leaf", + "required": false, + "schema": { + "items": { + "format": "date-time", + "type": "string" + }, + "type": "array" + }, + "style": "form" + }, + { + "description": "Only a subsequence of the temporal geometry clipped to the datetime interval is returned. The datetime parameter is then a bounded interval and the leaf parameter is not used.", + "explode": false, + "in": "query", + "name": "subTrajectory", + "required": false, + "schema": { + "default": false, + "type": "boolean" + }, + "style": "form" + } + ], + "responses": { + "200": { + "description": "OK" + } + }, + "summary": "Temporal geometry sequence (TemporalGeometrySequence; members addressable by their 1-based id)" + }, + "post": { + "responses": { + "200": { + "description": "OK" + } + }, + "summary": "Append a temporally-disjoint member sequence" + } + }, + "/collections/{cid}/items/{fid}/tgsequence/queries": { + "get": { + "responses": { + "200": { + "description": "OK" + } + }, + "summary": "List the geometry (position) continuous queries of a feature" + }, + "post": { + "responses": { + "200": { + "description": "OK" + } + }, + "summary": "Register a geometry continuous query streaming the moving feature's position" + } + }, + "/collections/{cid}/items/{fid}/tgsequence/queries/{qid}": { + "delete": { + "responses": { + "200": { + "description": "OK" + } + }, + "summary": "Stop a geometry continuous query" + }, + "get": { + "responses": { + "200": { + "description": "OK" + } + }, + "summary": "Geometry continuous-query status (the cquery link object)" + } + }, + "/collections/{cid}/items/{fid}/tgsequence/queries/{qid}/stream": { + "get": { + "responses": { + "200": { + "description": "OK" + } + }, + "summary": "Moving-feature positions as Server-Sent Events (see api/streaming-asyncapi.yaml)" + } + }, + "/collections/{cid}/items/{fid}/tgsequence/{tgid}": { + "delete": { + "responses": { + "200": { + "description": "OK" + } + }, + "summary": "Delete a temporal primitive geometry (member sequence) by id" + } + }, + "/collections/{cid}/items/{fid}/tgsequence/{tgid}/{qtype}": { + "get": { + "responses": { + "200": { + "description": "OK" + } + }, + "summary": "Derived query on a member geometry: distance | velocity (acceleration → 501, not derivable for this motion model)" + } + }, + "/collections/{cid}/items/{fid}/tproperties": { + "get": { + "parameters": [ + { + "description": "The optional limit parameter limits the number of items presented in the response document.", + "explode": false, + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 10000, + "minimum": 1, + "type": "integer" + }, + "style": "form" + }, + { + "description": "Either a date-time or an interval. Only features that have a temporal geometry or temporal property that intersects the value are selected.", + "explode": false, + "in": "query", + "name": "datetime", + "required": false, + "schema": { + "type": "string" + }, + "style": "form" + }, + { + "description": "Only a subsequence of the temporal property clipped to the datetime interval is returned. The datetime parameter is then a bounded interval and the leaf parameter is not used.", + "explode": false, + "in": "query", + "name": "subTemporalValue", + "required": false, + "schema": { + "default": false, + "type": "boolean" + }, + "style": "form" + } + ], + "responses": { + "200": { + "description": "OK" + } + }, + "summary": "Stored temporal properties of a feature" + }, + "post": { + "responses": { + "200": { + "description": "OK" + } + }, + "summary": "Add one or more temporal properties (TReal | TInteger | TText | TBoolean) to a feature" + } + }, + "/collections/{cid}/items/{fid}/tproperties/{pname}": { + "delete": { + "responses": { + "200": { + "description": "OK" + } + }, + "summary": "Delete a temporal property" + }, + "get": { + "parameters": [ + { + "description": "Either a date-time or an interval. Only features that have a temporal geometry or temporal property that intersects the value are selected.", + "explode": false, + "in": "query", + "name": "datetime", + "required": false, + "schema": { + "type": "string" + }, + "style": "form" + }, + { + "description": "Only features with a temporal geometry or temporal property that intersect the given date-times are selected. The date-times are given as a comma-separated list.", + "explode": false, + "in": "query", + "name": "leaf", + "required": false, + "schema": { + "items": { + "format": "date-time", + "type": "string" + }, + "type": "array" + }, + "style": "form" + }, + { + "description": "Only a subsequence of the temporal property clipped to the datetime interval is returned. The datetime parameter is then a bounded interval and the leaf parameter is not used.", + "explode": false, + "in": "query", + "name": "subTemporalValue", + "required": false, + "schema": { + "default": false, + "type": "boolean" + }, + "style": "form" + } + ], + "responses": { + "200": { + "description": "OK" + } + }, + "summary": "A stored temporal property as an OGC temporalProperty" + }, + "post": { + "responses": { + "200": { + "description": "OK" + } + }, + "summary": "Append values to a temporal property (temporally disjoint; overlap → 409)" + } + }, + "/collections/{cid}/items/{fid}/tproperties/{pname}/ingest": { + "post": { + "responses": { + "200": { + "description": "OK" + } + }, + "summary": "Push a live record ({datetime, value}) to the live queries on the property" + } + }, + "/collections/{cid}/items/{fid}/tproperties/{pname}/queries": { + "get": { + "responses": { + "200": { + "description": "OK" + } + }, + "summary": "List the continuous queries on a temporal property" + }, + "post": { + "responses": { + "200": { + "description": "OK" + } + }, + "summary": "Register a continuous query (MF Part 4): a lifted transform (operation), or a windowed aggregation (aggregation + window: COUNT | TUMBLING | HOPPING). Set live:true to source from pushed records" + } + }, + "/collections/{cid}/items/{fid}/tproperties/{pname}/queries/{qid}": { + "delete": { + "responses": { + "200": { + "description": "OK" + } + }, + "summary": "Stop a continuous query" + }, + "get": { + "responses": { + "200": { + "description": "OK" + } + }, + "summary": "Continuous-query status (the cquery link object)" + } + }, + "/collections/{cid}/items/{fid}/tproperties/{pname}/queries/{qid}/stream": { + "get": { + "responses": { + "200": { + "description": "OK" + } + }, + "summary": "Continuous-query results as Server-Sent Events (see api/streaming-asyncapi.yaml)" + } + }, + "/collections/{cid}/items/{fid}/tproperties/{pname}/{tvid}": { + "delete": { + "responses": { + "200": { + "description": "OK" + } + }, + "summary": "Delete a temporal primitive value (member value sequence) by its 1-based id" + } + }, + "/conformance": { + "get": { + "responses": { + "200": { + "description": "OK" + } + }, + "summary": "Conformance declaration" + } + } + } +} + diff --git a/samples/collection.json b/samples/collection.json new file mode 100644 index 0000000..e5dda3d --- /dev/null +++ b/samples/collection.json @@ -0,0 +1,47 @@ +{ + "crs": [ + "http://www.opengis.net/def/crs/EPSG/0/25832" + ], + "description": "A fixed two-feature corpus for the OGC API - Moving Features Part 1 abstract tests", + "extent": { + "spatial": { + "bbox": [ + [ + 10.189934628004437, + 56.11035654255155, + 10.271202546128217, + 56.136503619599175 + ] + ], + "crs": "http://www.opengis.net/def/crs/OGC/1.3/CRS84" + }, + "temporal": { + "interval": [ + [ + "2026-01-01T08:00:00Z", + "2026-01-01T09:30:00Z" + ] + ], + "trs": "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian" + } + }, + "id": "conformance", + "itemType": "movingfeature", + "links": [ + { + "href": "/collections/conformance", + "rel": "self" + }, + { + "href": "/collections/conformance/items", + "rel": "items" + }, + { + "href": "/collections/conformance/export", + "rel": "enclosure", + "type": "application/x-ndjson" + } + ], + "title": "conformance" +} + diff --git a/samples/collections.json b/samples/collections.json new file mode 100644 index 0000000..9d8bcb3 --- /dev/null +++ b/samples/collections.json @@ -0,0 +1,59 @@ +{ + "collections": [ + { + "crs": [ + "http://www.opengis.net/def/crs/EPSG/0/25832" + ], + "description": "A fixed two-feature corpus for the OGC API - Moving Features Part 1 abstract tests", + "id": "conformance", + "itemType": "movingfeature", + "links": [ + { + "href": "/collections/conformance", + "rel": "self" + }, + { + "href": "/collections/conformance/items", + "rel": "items" + }, + { + "href": "/collections/conformance/export", + "rel": "enclosure", + "type": "application/x-ndjson" + } + ], + "title": "conformance" + }, + { + "crs": [ + "http://www.opengis.net/def/crs/EPSG/0/25832" + ], + "description": "A second collection, so a delete cannot pass by acting on the only one", + "id": "conformance_alt", + "itemType": "movingfeature", + "links": [ + { + "href": "/collections/conformance_alt", + "rel": "self" + }, + { + "href": "/collections/conformance_alt/items", + "rel": "items" + }, + { + "href": "/collections/conformance_alt/export", + "rel": "enclosure", + "type": "application/x-ndjson" + } + ], + "title": "conformance_alt" + } + ], + "links": [ + { + "href": "/collections", + "rel": "self" + } + ] +} + diff --git a/samples/conformance-declaration.json b/samples/conformance-declaration.json new file mode 100644 index 0000000..fcb0f70 --- /dev/null +++ b/samples/conformance-declaration.json @@ -0,0 +1,11 @@ +{ + "conformsTo": [ + "http://www.opengis.net/spec/ogcapi-movingfeatures-1/1.0/conf/common", + "http://www.opengis.net/spec/ogcapi-movingfeatures-1/1.0/conf/mf-collection", + "http://www.opengis.net/spec/ogcapi-movingfeatures-1/1.0/conf/movingfeatures", + "http://www.opengis.net/spec/ogcapi-movingfeatures-4/1.0/conf/cquery", + "http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/core", + "http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/collections" + ] +} + diff --git a/samples/landing-page.json b/samples/landing-page.json new file mode 100644 index 0000000..778e7f4 --- /dev/null +++ b/samples/landing-page.json @@ -0,0 +1,27 @@ +{ + "description": "OGC API – Moving Features over MobilityDB", + "links": [ + { + "href": "/", + "rel": "self", + "type": "application/json" + }, + { + "href": "/api", + "rel": "service-desc", + "type": "application/vnd.oai.openapi+json;version=3.0" + }, + { + "href": "/conformance", + "rel": "conformance", + "type": "application/json" + }, + { + "href": "/collections", + "rel": "data", + "type": "application/json" + } + ], + "title": "MobilityAPI-go" +} + diff --git a/samples/moving-feature-collection.json b/samples/moving-feature-collection.json new file mode 100644 index 0000000..b953432 --- /dev/null +++ b/samples/moving-feature-collection.json @@ -0,0 +1,147 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "id": "1", + "properties": { + "mmsi": 219000001, + "name": "Alpha" + }, + "crs": { + "type": "Name", + "properties": { + "name": "urn:ogc:def:crs:EPSG::25832" + } + }, + "trs": { + "type": "Link", + "properties": { + "href": "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian", + "type": "ogcdef" + } + }, + "bbox": [ + 575000, + 6220000, + 579000, + 6222000 + ], + "time": [ + "2026-01-01T08:00:00Z", + "2026-01-01T08:45:00Z" + ], + "temporalGeometry": { + "type": "MovingPoint", + "crs": { + "type": "Name", + "properties": { + "name": "urn:ogc:def:crs:EPSG::25832" + } + }, + "coordinates": [ + [ + 575000, + 6220000 + ], + [ + 576000, + 6220500 + ], + [ + 577500, + 6221000 + ], + [ + 579000, + 6222000 + ] + ], + "datetimes": [ + "2026-01-01T08:00:00+00:00", + "2026-01-01T08:10:00+00:00", + "2026-01-01T08:25:00+00:00", + "2026-01-01T08:45:00+00:00" + ], + "lower_inc": true, + "upper_inc": true, + "interpolation": "Linear" + }, + "links": [ + { + "rel": "self", + "href": "/collections/conformance/items/1" + } + ] + }, + { + "type": "Feature", + "id": "2", + "properties": { + "mmsi": 219000002, + "name": "Bravo" + }, + "crs": { + "type": "Name", + "properties": { + "name": "urn:ogc:def:crs:EPSG::25832" + } + }, + "trs": { + "type": "Link", + "properties": { + "href": "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian", + "type": "ogcdef" + } + }, + "bbox": [ + 574000, + 6219000, + 575600, + 6220100 + ], + "time": [ + "2026-01-01T09:00:00Z", + "2026-01-01T09:30:00Z" + ], + "temporalGeometry": { + "type": "MovingPoint", + "crs": { + "type": "Name", + "properties": { + "name": "urn:ogc:def:crs:EPSG::25832" + } + }, + "coordinates": [ + [ + 574000, + 6219000 + ], + [ + 574800, + 6219400 + ], + [ + 575600, + 6220100 + ] + ], + "datetimes": [ + "2026-01-01T09:00:00+00:00", + "2026-01-01T09:12:00+00:00", + "2026-01-01T09:30:00+00:00" + ], + "lower_inc": true, + "upper_inc": true, + "interpolation": "Linear" + }, + "links": [ + { + "rel": "self", + "href": "/collections/conformance/items/2" + } + ] + } + ], + "numberReturned": 2 +} diff --git a/samples/moving-feature.json b/samples/moving-feature.json new file mode 100644 index 0000000..95cc71d --- /dev/null +++ b/samples/moving-feature.json @@ -0,0 +1,100 @@ +{ + "type": "Feature", + "id": "1", + "properties": { + "mmsi": 219000001, + "name": "Alpha" + }, + "crs": { + "type": "Name", + "properties": { + "name": "urn:ogc:def:crs:EPSG::25832" + } + }, + "trs": { + "type": "Link", + "properties": { + "href": "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian", + "type": "ogcdef" + } + }, + "bbox": [ + 575000, + 6220000, + 579000, + 6222000 + ], + "time": [ + "2026-01-01T08:00:00Z", + "2026-01-01T08:45:00Z" + ], + "temporalGeometry": { + "type": "MovingPoint", + "crs": { + "type": "Name", + "properties": { + "name": "urn:ogc:def:crs:EPSG::25832" + } + }, + "coordinates": [ + [ + 575000, + 6220000 + ], + [ + 576000, + 6220500 + ], + [ + 577500, + 6221000 + ], + [ + 579000, + 6222000 + ] + ], + "datetimes": [ + "2026-01-01T08:00:00+00:00", + "2026-01-01T08:10:00+00:00", + "2026-01-01T08:25:00+00:00", + "2026-01-01T08:45:00+00:00" + ], + "lower_inc": true, + "upper_inc": true, + "interpolation": "Linear" + }, + "geometry": { + "type": "LineString", + "crs": { + "type": "name", + "properties": { + "name": "urn:ogc:def:crs:EPSG::25832" + } + }, + "coordinates": [ + [ + 575000, + 6220000 + ], + [ + 576000, + 6220500 + ], + [ + 577500, + 6221000 + ], + [ + 579000, + 6222000 + ] + ] + }, + "links": [ + { + "rel": "self", + "href": "/collections/conformance/items/1" + } + ] +} diff --git a/samples/temporal-geometry-acceleration.json b/samples/temporal-geometry-acceleration.json new file mode 100644 index 0000000..df5cdb8 --- /dev/null +++ b/samples/temporal-geometry-acceleration.json @@ -0,0 +1,5 @@ +{ + "code": "501", + "description": "acceleration is not derivable: linearly interpolated position gives a piecewise-constant (Step) speed, whose derivative is zero within each segment and undefined at the vertices" +} + diff --git a/samples/temporal-geometry-distance.json b/samples/temporal-geometry-distance.json new file mode 100644 index 0000000..a266f37 --- /dev/null +++ b/samples/temporal-geometry-distance.json @@ -0,0 +1,31 @@ +{ + "name": "distance", + "type": "TReal", + "form": "http://www.opengis.net/def/uom/UCUM/0/m", + "description": "Cumulative distance travelled along the trajectory.", + "valueSequence": [ + { + "datetimes": [ + "2026-01-01T08:00:00+00:00", + "2026-01-01T08:10:00+00:00", + "2026-01-01T08:25:00+00:00", + "2026-01-01T08:45:00+00:00" + ], + "interpolation": "Linear", + "lower_inc": true, + "upper_inc": true, + "values": [ + 0, + 1118.033988749895, + 2699.172818834085, + 4501.94845656608 + ] + } + ], + "links": [ + { + "rel": "self", + "href": "/collections/conformance/items/1/tgsequence/1/distance" + } + ] +} diff --git a/samples/temporal-geometry-sequence.json b/samples/temporal-geometry-sequence.json new file mode 100644 index 0000000..3373914 --- /dev/null +++ b/samples/temporal-geometry-sequence.json @@ -0,0 +1,48 @@ +{ + "type": "TemporalGeometrySequence", + "geometrySequence": [ + { + "coordinates": [ + [ + 575000, + 6220000 + ], + [ + 576000, + 6220500 + ], + [ + 577500, + 6221000 + ], + [ + 579000, + 6222000 + ] + ], + "crs": { + "type": "Name", + "properties": { + "name": "urn:ogc:def:crs:EPSG::25832" + } + }, + "datetimes": [ + "2026-01-01T08:00:00+00:00", + "2026-01-01T08:10:00+00:00", + "2026-01-01T08:25:00+00:00", + "2026-01-01T08:45:00+00:00" + ], + "id": 1, + "interpolation": "Linear", + "lower_inc": true, + "type": "MovingPoint", + "upper_inc": true + } + ], + "links": [ + { + "rel": "self", + "href": "/collections/conformance/items/1/tgsequence" + } + ] +} diff --git a/samples/temporal-geometry-velocity.json b/samples/temporal-geometry-velocity.json new file mode 100644 index 0000000..0f53018 --- /dev/null +++ b/samples/temporal-geometry-velocity.json @@ -0,0 +1,31 @@ +{ + "name": "velocity", + "type": "TReal", + "form": "http://www.opengis.net/def/uom/UCUM/0/m_s-1", + "description": "Speed over ground (velocity magnitude), a piecewise-constant function of the trajectory.", + "valueSequence": [ + { + "datetimes": [ + "2026-01-01T08:00:00+00:00", + "2026-01-01T08:10:00+00:00", + "2026-01-01T08:25:00+00:00", + "2026-01-01T08:45:00+00:00" + ], + "interpolation": "Stepwise", + "lower_inc": true, + "upper_inc": true, + "values": [ + 1.863389981249825, + 1.756820922315766, + 1.502313031443329, + 1.502313031443329 + ] + } + ], + "links": [ + { + "rel": "self", + "href": "/collections/conformance/items/1/tgsequence/1/velocity" + } + ] +} diff --git a/samples/temporal-properties.json b/samples/temporal-properties.json new file mode 100644 index 0000000..ac79cbb --- /dev/null +++ b/samples/temporal-properties.json @@ -0,0 +1,60 @@ +{ + "links": [ + { + "href": "/collections/conformance/items/1/tproperties", + "rel": "self" + } + ], + "numberMatched": 4, + "numberReturned": 4, + "temporalProperties": [ + { + "description": "Whether the vessel is at anchor", + "links": [ + { + "href": "/collections/conformance/items/1/tproperties/anchored", + "rel": "self" + } + ], + "name": "anchored", + "type": "TBoolean" + }, + { + "description": "Course over ground", + "form": "http://www.opengis.net/def/uom/UCUM/0/deg", + "links": [ + { + "href": "/collections/conformance/items/1/tproperties/heading", + "rel": "self" + } + ], + "name": "heading", + "type": "TInteger" + }, + { + "description": "Speed over ground", + "form": "http://www.opengis.net/def/uom/UCUM/0/km_h-1", + "links": [ + { + "href": "/collections/conformance/items/1/tproperties/speed", + "rel": "self" + } + ], + "name": "speed", + "type": "TReal" + }, + { + "description": "Navigational status", + "links": [ + { + "href": "/collections/conformance/items/1/tproperties/status", + "rel": "self" + } + ], + "name": "status", + "type": "TText" + } + ], + "timeStamp": "2026-08-29T17:08:43Z" +} + diff --git a/samples/temporal-property.json b/samples/temporal-property.json new file mode 100644 index 0000000..1b98903 --- /dev/null +++ b/samples/temporal-property.json @@ -0,0 +1,26 @@ +{ + "name": "anchored", + "type": "TBoolean", + "description": "Whether the vessel is at anchor", + "valueSequence": [ + { + "datetimes": [ + "2026-01-01T08:00:00+00:00", + "2026-01-01T08:45:00+00:00" + ], + "interpolation": "Stepwise", + "lower_inc": true, + "upper_inc": true, + "values": [ + false, + true + ] + } + ], + "links": [ + { + "rel": "self", + "href": "/collections/conformance/items/1/tproperties/anchored" + } + ] +} diff --git a/stream.go b/stream.go index e8a5d3f..cf329b2 100644 --- a/stream.go +++ b/stream.go @@ -32,7 +32,7 @@ import ( // Instant is one stream record: a temporal-property value at a timestamp. The // timestamp is carried verbatim as its ISO-8601 string so it round-trips // through MEOS without a parse/format step. V holds a numeric value (TReal / -// TInt); S holds a text value (TText) or "t"/"f" (TBool). +// TInteger); S holds a text value (TText) or "t"/"f" (TBoolean). type Instant struct { T string `json:"datetime"` V float64 `json:"value"` @@ -51,7 +51,7 @@ type QuerySpec struct { Arg float64 // operand for scalar ops (add/sub/mul/div); ignored otherwise Agg string // a window aggregation, e.g. "AVG" or "MAX" Window Window // the window over which Agg is computed - Ptype string // the property's OGC type (TReal | TInt | TText | TBool) + Ptype string // the property's OGC type (TReal | TInteger | TText | TBoolean) Interval time.Duration // pacing between emitted records Engine string // per-query engine override ("flink"|"kafka"|"meos-local"); empty = the process default } @@ -72,10 +72,10 @@ type Window struct { // the window's values; text and boolean aggregations reduce the MEOS value // arrays. var aggByType = map[string]map[string]bool{ - "TReal": {"COUNT": true, "SUM": true, "AVG": true, "MIN": true, "MAX": true}, - "TInt": {"COUNT": true, "SUM": true, "AVG": true, "MIN": true, "MAX": true}, - "TText": {"COUNT": true, "COUNT_DISTINCT": true}, - "TBool": {"COUNT": true, "ANY": true, "ALL": true, "COUNT_TRUE": true, "COUNT_FALSE": true}, + "TReal": {"COUNT": true, "SUM": true, "AVG": true, "MIN": true, "MAX": true}, + "TInteger": {"COUNT": true, "SUM": true, "AVG": true, "MIN": true, "MAX": true}, + "TText": {"COUNT": true, "COUNT_DISTINCT": true}, + "TBoolean": {"COUNT": true, "ANY": true, "ALL": true, "COUNT_TRUE": true, "COUNT_FALSE": true}, } // aggregations is the union of all valid aggregation names (the engine validates diff --git a/stream_engine_meos.go b/stream_engine_meos.go index aae9dd1..05248c6 100644 --- a/stream_engine_meos.go +++ b/stream_engine_meos.go @@ -361,7 +361,7 @@ func windowAggregate(ptype, agg string, win []Instant) (any, int, error) { switch ptype { case "TText": return textAggregate(agg, win) - case "TBool": + case "TBoolean": return boolAggregate(agg, win) default: return numAggregate(agg, win) diff --git a/stream_meos_test.go b/stream_meos_test.go index 786ba58..13d30dd 100644 --- a/stream_meos_test.go +++ b/stream_meos_test.go @@ -127,7 +127,7 @@ func TestMeosTextBoolAggregate(t *testing.T) { }{{"ANY", true}, {"ALL", false}, {"COUNT_TRUE", 1.0}} { ctx, cancel := context.WithCancel(context.Background()) src := make(chan Instant, 3) - h, err := e.Submit(ctx, QuerySpec{Ptype: "TBool", Agg: c.agg, Window: Window{Type: "COUNT", Size: 3}}, src) + h, err := e.Submit(ctx, QuerySpec{Ptype: "TBoolean", Agg: c.agg, Window: Window{Type: "COUNT", Size: 3}}, src) if err != nil { t.Fatal(err) } diff --git a/tproperties_test.go b/tproperties_test.go index 3832f9d..7e97fe5 100644 --- a/tproperties_test.go +++ b/tproperties_test.go @@ -12,12 +12,12 @@ func TestTPropType(t *testing.T) { "": {"MovingFloat", "vfloat", "tfloat", "TReal", "Linear"}, "TReal": {"MovingFloat", "vfloat", "tfloat", "TReal", "Linear"}, "measure": {"MovingFloat", "vfloat", "tfloat", "TReal", "Linear"}, - "TInt": {"MovingInteger", "vint", "tint", "TInt", "Step"}, - "integer": {"MovingInteger", "vint", "tint", "TInt", "Step"}, + "TInt": {"MovingInteger", "vint", "tint", "TInteger", "Step"}, + "integer": {"MovingInteger", "vint", "tint", "TInteger", "Step"}, "TText": {"MovingText", "vtext", "ttext", "TText", "Discrete"}, "string": {"MovingText", "vtext", "ttext", "TText", "Discrete"}, - "TBool": {"MovingBoolean", "vbool", "tbool", "TBool", "Step"}, - "BOOLEAN ": {"MovingBoolean", "vbool", "tbool", "TBool", "Step"}, + "TBool": {"MovingBoolean", "vbool", "tbool", "TBoolean", "Step"}, + "BOOLEAN ": {"MovingBoolean", "vbool", "tbool", "TBoolean", "Step"}, } for in, want := range cases { got, ok := tPropType(in)