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
35 changes: 35 additions & 0 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<written>"/' "$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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@ MobilityAPI-go
mfapi
mfapi-meos
live.log

# the sample set the CI freshness check re-emits for comparison
samples.emitted/
69 changes: 69 additions & 0 deletions ats_schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
21 changes: 18 additions & 3 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"log"
"net/http"
Expand Down Expand Up @@ -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"
Expand All @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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"),
Expand Down
205 changes: 205 additions & 0 deletions samples.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading