diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 44c45e30..a8ec813d 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -6,7 +6,7 @@ jobs: end-to-end-test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 # Build a local test image for (potential) re-use across end-to-end tests - name: Set up Docker Buildx diff --git a/.github/workflows/lint-docker.yml b/.github/workflows/lint-docker.yml new file mode 100644 index 00000000..b41c6e50 --- /dev/null +++ b/.github/workflows/lint-docker.yml @@ -0,0 +1,21 @@ +--- +name: lint (docker) +on: + push: + branches: + - master + pull_request: +jobs: + lint: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: | + Dockerfile + sparse-checkout-cone-mode: false + + - uses: hadolint/hadolint-action@v3.1.0 + with: + dockerfile: Dockerfile diff --git a/.github/workflows/lint-go.yml b/.github/workflows/lint-go.yml index 23dadf16..999a5741 100644 --- a/.github/workflows/lint-go.yml +++ b/.github/workflows/lint-go.yml @@ -20,7 +20,7 @@ jobs: go-version: '1.23' cache: false - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Tidy uses: katexochen/go-tidy-check@v2 diff --git a/.github/workflows/lint-ts.yml b/.github/workflows/lint-ts.yml index 375aa8c1..7a94d125 100644 --- a/.github/workflows/lint-ts.yml +++ b/.github/workflows/lint-ts.yml @@ -20,7 +20,7 @@ jobs: # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v3 with: diff --git a/.github/workflows/test-go.yml b/.github/workflows/test-go.yml index ac5dde16..f97d1222 100644 --- a/.github/workflows/test-go.yml +++ b/.github/workflows/test-go.yml @@ -11,7 +11,7 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup cgo dependencies run: sudo apt-get update && sudo apt-get install libcurl4-openssl-dev libssl-dev libsqlite3-mod-spatialite diff --git a/.github/workflows/test-ts.yml b/.github/workflows/test-ts.yml index 78b686e2..e0e92e87 100644 --- a/.github/workflows/test-ts.yml +++ b/.github/workflows/test-ts.yml @@ -18,7 +18,7 @@ jobs: node-version: [20.x] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v3 diff --git a/.golangci.yaml b/.golangci.yaml index 7f398ded..688a8ebd 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -16,6 +16,7 @@ issues: linters: - bodyclose - dupl + - dogsled - funlen output: @@ -66,15 +67,19 @@ linters: - cyclop # checks function and package cyclomatic complexity - dupl # tool for code clone detection - durationcheck # checks for two durations multiplied together + - dogsled # find assignments/declarations with too many blank identifiers - errname # checks that sentinel errors are prefixed with the Err and error types are suffixed with the Error - errorlint # finds code that will cause problems with the error wrapping scheme introduced in Go 1.13 - exhaustive # checks exhaustiveness of enum switch statements + - exptostd # detects functions from golang.org/x/exp/ that can be replaced by std functions - copyloopvar # checks for pointers to enclosing loop variables + - fatcontext # detects nested contexts in loops and function literals - forbidigo # forbids identifiers - funlen # tool for detection of long functions - gocheckcompilerdirectives # validates go compiler directive comments (//go:) - goconst # finds repeated strings that could be replaced by a constant - gocritic # provides diagnostics that check for bugs, performance and style issues + - gofmt # checks if the code is formatted according to 'gofmt' command - goimports # in addition to fixing imports, goimports also formats your code in the same style as gofmt - gomoddirectives # manages the use of 'replace', 'retract', and 'excludes' directives in go.mod - gomodguard # allow and block lists linter for direct Go module dependencies. This is different from depguard where there are different block types for example version constraints and module recommendations @@ -82,6 +87,7 @@ linters: - gosec # inspects source code for security problems - loggercheck # checks key value pairs for common logger libraries (kitlog,klog,logr,zap) - makezero # finds slice declarations with non-zero initial length + - mirror # reports wrong mirror patterns of bytes/strings usage - misspell # finds commonly misspelled English words - nakedret # finds naked returns in functions greater than a specified function length - nestif # reports deeply nested if statements @@ -96,6 +102,7 @@ linters: - rowserrcheck # checks whether Err of rows is checked successfully - sqlclosecheck # checks that sql.Rows and sql.Stmt are closed - sloglint # A Go linter that ensures consistent code style when using log/slog + - tagliatelle # checks the struct tags. - tenv # detects using os.Setenv instead of t.Setenv since Go1.17 - testableexamples # checks if examples are testable (have an expected output) - tparallel # detects inappropriate usage of t.Parallel() method in your Go test codes diff --git a/Dockerfile b/Dockerfile index 55d0f89d..2b9fd7f6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,13 +3,14 @@ FROM docker.io/node:lts-alpine3.17 AS build-component RUN mkdir -p /usr/src/app COPY ./viewer /usr/src/app WORKDIR /usr/src/app -RUN npm install -RUN npm run build +RUN npm config set registry http://registry.npmjs.org && \ + npm install && \ + npm run build ####### Go build FROM docker.io/golang:1.23-bookworm AS build-env WORKDIR /go/src/service -ADD . /go/src/service +COPY . /go/src/service # enable cgo in order to interface with sqlite ENV CGO_ENABLED=1 @@ -18,7 +19,7 @@ ENV GOOS=linux # install sqlite-related compile-time dependencies RUN set -eux && \ apt-get update && \ - apt-get install -y libcurl4-openssl-dev libssl-dev libsqlite3-mod-spatialite && \ + apt-get install --no-install-recommends -y libcurl4-openssl-dev=* libssl-dev=* libsqlite3-mod-spatialite=* && \ rm -rf /var/lib/apt/lists/* # install controller-gen (used by go generate) @@ -40,7 +41,7 @@ FROM docker.io/debian:bookworm-slim # install sqlite-related runtime dependencies RUN set -eux && \ apt-get update && \ - apt-get install -y libcurl4 curl openssl libsqlite3-mod-spatialite && \ + apt-get install --no-install-recommends -y libcurl4=* curl=* openssl=* ca-certificates=* libsqlite3-mod-spatialite=* && \ rm -rf /var/lib/apt/lists/* EXPOSE 8080 diff --git a/cmd/main_test.go b/cmd/main_test.go index 075396d0..6ed6700b 100644 --- a/cmd/main_test.go +++ b/cmd/main_test.go @@ -47,19 +47,19 @@ func Test_newRouter(t *testing.T) { { name: "Serve multiple OGC APIs for single collection in JSON", configFile: "internal/engine/testdata/config_multiple_ogc_apis_single_collection.yaml", - apiCall: "http://localhost:8180/collections/NewYork?f=json", + apiCall: "http://localhost:8180/collections/newyork?f=json", wantBody: "internal/engine/testdata/expected_multiple_ogc_apis_single_collection.json", }, { name: "Serve multiple OGC APIs for single collection in HTML", configFile: "internal/engine/testdata/config_multiple_ogc_apis_single_collection.yaml", - apiCall: "http://localhost:8180/collections/NewYork?f=html", + apiCall: "http://localhost:8180/collections/newyork?f=html", wantBody: "internal/engine/testdata/expected_multiple_ogc_apis_single_collection.html", }, { name: "Serve JSON-LD in multiple OGC APIs for single collection in HTML", configFile: "internal/engine/testdata/config_multiple_ogc_apis_single_collection.yaml", - apiCall: "http://localhost:8180/collections/NewYork?f=html", + apiCall: "http://localhost:8180/collections/newyork?f=html", wantBody: "internal/engine/testdata/expected_multiple_ogc_apis_single_collection_json_ld.html", }, { diff --git a/config/README.md b/config/README.md index 0e08b0d8..7c045ebc 100644 --- a/config/README.md +++ b/config/README.md @@ -1,6 +1,7 @@ # Config -This config package is used to validate and unmarshall a GoKoala YAML config file to structs. +This config package is used to validate and unmarshal a GoKoala YAML config file to Go structs. In addition, this package is imported as a _library_ in the PDOK [OGCAPI operator](https://github.com/PDOK/ogcapi-operator) -to validate the `OGCAPI` Custom Resource (CR) in order to orchestrate GoKoala in Kubernetes. \ No newline at end of file +to validate the `OGCAPI` Custom Resource (CR) in order to orchestrate GoKoala in Kubernetes. +For this reason the structs in the package are annotated with [Kubebuilder markers](https://book.kubebuilder.io/reference/markers). \ No newline at end of file diff --git a/config/collections.go b/config/collections.go index 72ec798c..b629c4ab 100644 --- a/config/collections.go +++ b/config/collections.go @@ -15,7 +15,8 @@ type GeoSpatialCollections []GeoSpatialCollection // +kubebuilder:object:generate=true type GeoSpatialCollection struct { // Unique ID of the collection - ID string `yaml:"id" validate:"required" json:"id"` + // +kubebuilder:validation:Pattern=`^[a-z0-9"]([a-z0-9_-]*[a-z0-9"]+|)$` + ID string `yaml:"id" validate:"required,lowercase_id" json:"id"` // Metadata describing the collection contents // +optional diff --git a/config/config.go b/config/config.go index 4d12f9b9..a6aa39fc 100644 --- a/config/config.go +++ b/config/config.go @@ -226,36 +226,20 @@ func setDefaults(config *Config) error { if len(config.AvailableLanguages) == 0 { config.AvailableLanguages = append(config.AvailableLanguages, Language{language.Dutch}) // default to Dutch only } - if config.OgcAPI.Tiles != nil && config.OgcAPI.Tiles.DatasetTiles != nil && config.OgcAPI.Tiles.DatasetTiles.HealthCheck.Srs == DefaultSrs && - config.OgcAPI.Tiles.DatasetTiles.HealthCheck.TilePath == nil { - setHealthCheckTilePath(config.OgcAPI.Tiles.DatasetTiles) - } else if config.OgcAPI.Tiles != nil && config.OgcAPI.Tiles.Collections != nil { - for _, coll := range config.OgcAPI.Tiles.Collections { - if coll.Tiles.GeoDataTiles.HealthCheck.Srs == DefaultSrs && coll.Tiles.GeoDataTiles.HealthCheck.TilePath == nil { - setHealthCheckTilePath(&coll.Tiles.GeoDataTiles) - } - } + if config.OgcAPI.Tiles != nil { + config.OgcAPI.Tiles.Defaults() } return nil } -func setHealthCheckTilePath(tilesConfig *Tiles) { - var deepestZoomLevel int - for _, srs := range tilesConfig.SupportedSrs { - if srs.Srs == DefaultSrs { - deepestZoomLevel = srs.ZoomLevelRange.End - } - } - defaultTile := HealthCheckDefaultTiles[deepestZoomLevel] - tileMatrixSet := AllTileProjections[DefaultSrs] - tilePath := fmt.Sprintf("/%s/%d/%d/%d.pbf", tileMatrixSet, deepestZoomLevel, defaultTile.x, defaultTile.y) - tilesConfig.HealthCheck.TilePath = &tilePath -} - func validate(config *Config) error { // process 'validate' tags v := validator.New() - err := v.Struct(config) + err := v.RegisterValidation(lowercaseID, LowercaseID) + if err != nil { + return err + } + err = v.Struct(config) if err != nil { var ive *validator.InvalidValidationError if ok := errors.Is(err, ive); ok { diff --git a/config/config_test.go b/config/config_test.go index aef7495f..200af8b6 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -46,13 +46,28 @@ func TestNewConfig(t *testing.T) { wantErr: false, }, { - name: "fail on invalid config file", + name: "fail on invalid config file with wrong version number", args: args{ configFile: "internal/engine/testdata/config_invalid.yaml", }, wantErr: true, wantErrMsg: "validation for 'Version' failed on the 'semver' tag", }, + { + name: "fail on invalid config file with wrong collection IDs", + args: args{ + configFile: "internal/engine/testdata/config_invalid_collection_ids.yaml", + }, + wantErr: true, + wantErrMsg: "Field validation for 'ID' failed on the 'lowercase_id' tag", + }, + { + name: "read config file with valid collection IDs", + args: args{ + configFile: "internal/engine/testdata/config_valid_collection_ids.yaml", + }, + wantErr: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -81,28 +96,28 @@ func TestGeoSpatialCollections_Ordering(t *testing.T) { name: "should return collections in default order (alphabetic)", args: args{ configFile: "internal/engine/testdata/config_collections_order_alphabetic.yaml", - expectedOrder: []string{"A", "B", "C", "Z", "Z"}, + expectedOrder: []string{"a", "b", "c", "z", "z"}, }, }, { name: "should return collections in default order (alphabetic) - by title", args: args{ configFile: "internal/engine/testdata/config_collections_order_alphabetic_titles.yaml", - expectedOrder: []string{"B", "C", "Z", "Z", "A"}, + expectedOrder: []string{"b", "c", "z", "z", "a"}, }, }, { name: "should return collections in default order (alphabetic) - extra test", args: args{ configFile: "internal/engine/testdata/config_collections_unique.yaml", - expectedOrder: []string{"BarCollection", "FooCollection", "FooCollection"}, + expectedOrder: []string{"bar_collection", "foo_collection", "foo_collection"}, }, }, { name: "should return collections in explicit / literal order", args: args{ configFile: "internal/engine/testdata/config_collections_order_literal.yaml", - expectedOrder: []string{"Z", "Z", "C", "A", "B"}, + expectedOrder: []string{"z", "z", "c", "a", "b"}, }, }, { @@ -305,7 +320,7 @@ func TestGeoSpatialCollection_Marshalling_JSON(t *testing.T) { } type TestEmbeddedGeoSpatialCollection struct { - C GeoSpatialCollection `json:"C"` + C GeoSpatialCollection `json:"c"` } func TestGeoSpatialCollection_Unmarshalling_JSON(t *testing.T) { diff --git a/config/duration_test.go b/config/duration_test.go index bba5975e..1ef0a9ff 100644 --- a/config/duration_test.go +++ b/config/duration_test.go @@ -10,7 +10,7 @@ import ( ) type TestEmbeddedDuration struct { - D Duration `json:"D" yaml:"D"` + D Duration `json:"d" yaml:"d"` } func TestDuration_DeepCopy(t *testing.T) { @@ -77,7 +77,7 @@ func TestDuration_Marshalling_JSON(t *testing.T) { // non-pointer unmarshalledEmbedded := &TestEmbeddedDuration{} - err = yaml.Unmarshal([]byte(`{"D": `+tt.want+`}`), unmarshalledEmbedded) + err = yaml.Unmarshal([]byte(`{"d": `+tt.want+`}`), unmarshalledEmbedded) if !tt.wantErr(t, err, errors.New("yaml.Unmarshal")) { return } diff --git a/config/language_test.go b/config/language_test.go index 6e4a0dc4..a8925fe3 100644 --- a/config/language_test.go +++ b/config/language_test.go @@ -11,7 +11,7 @@ import ( ) type TestEmbeddedLanguage struct { - L Language `json:"L" yaml:"L"` + L Language `json:"l" yaml:"l"` } func TestLanguage_DeepCopy(t *testing.T) { @@ -78,7 +78,7 @@ func TestLanguage_Marshalling_JSON(t *testing.T) { // non-pointer unmarshalledEmbedded := &TestEmbeddedLanguage{} - err = yaml.Unmarshal([]byte(`{"L": `+tt.want+`}`), unmarshalledEmbedded) + err = yaml.Unmarshal([]byte(`{"l": `+tt.want+`}`), unmarshalledEmbedded) if !tt.wantErr(t, err, errors.New("yaml.Unmarshal")) { return } diff --git a/config/lowercase_id.go b/config/lowercase_id.go new file mode 100644 index 00000000..c6fe01e6 --- /dev/null +++ b/config/lowercase_id.go @@ -0,0 +1,28 @@ +package config + +import ( + "log" + "regexp" + + "github.com/go-playground/validator/v10" +) + +var ( + lowercaseIDRegexp = regexp.MustCompile("^[a-z0-9\"]([a-z0-9_-]*[a-z0-9\"]+|)$") +) + +const ( + lowercaseID = "lowercase_id" +) + +// LowercaseID is the validation function for validating if the current field +// is not empty and contains only lowercase chars, numbers, hyphens or underscores. +// It's similar to RFC 1035 DNS label but not the same. +func LowercaseID(fl validator.FieldLevel) bool { + valAsString := fl.Field().String() + valid := lowercaseIDRegexp.MatchString(valAsString) + if !valid { + log.Printf("Invalid ID %s", valAsString) + } + return valid +} diff --git a/config/mediatype_test.go b/config/mediatype_test.go index 088d30cc..4efc1991 100644 --- a/config/mediatype_test.go +++ b/config/mediatype_test.go @@ -11,7 +11,7 @@ import ( ) type TestEmbeddedMediaType struct { - M MediaType `json:"M" yaml:"M"` + M MediaType `json:"m" yaml:"m"` } func TestMediaType_DeepCopy(t *testing.T) { @@ -174,7 +174,7 @@ func TestMediaType_Unmarshalling_YAML(t *testing.T) { // non-pointer unmarshalledEmbedded := &TestEmbeddedMediaType{} - err = yaml.Unmarshal([]byte(`{"M": `+tt.mediaType+`}`), unmarshalledEmbedded) + err = yaml.Unmarshal([]byte(`{"m": `+tt.mediaType+`}`), unmarshalledEmbedded) if !tt.wantErr(t, err, errors.New("yaml.Unmarshal")) { return } diff --git a/config/ogcapi_3dgeovolumes.go b/config/ogcapi_3dgeovolumes.go index 0f1af361..ee80b364 100644 --- a/config/ogcapi_3dgeovolumes.go +++ b/config/ogcapi_3dgeovolumes.go @@ -32,7 +32,7 @@ type CollectionEntry3dGeoVolumes struct { // URI template for digital terrain model (DTM) in Quantized Mesh format, REQUIRED when you want to serve a DTM. // +optional - URITemplateDTM *string `yaml:"uriTemplateDTM,omitempty" json:"uriTemplateDTM,omitempty" validate:"required_without_all=URITemplate3dTiles"` + URITemplateDTM *string `yaml:"uriTemplateDTM,omitempty" json:"uriTemplateDTM,omitempty" validate:"required_without_all=URITemplate3dTiles"` //nolint:tagliatelle // grandfathered // Optional URL to 3D viewer to visualize the given collection of 3D Tiles. // +optional diff --git a/config/ogcapi_features.go b/config/ogcapi_features.go index 89cfebdc..a6ebcffd 100644 --- a/config/ogcapi_features.go +++ b/config/ogcapi_features.go @@ -97,7 +97,7 @@ type CollectionEntryFeatures struct { type Datasources struct { // Features should always be available in WGS84 (according to spec). // This specifies the datasource to be used for features in the WGS84 projection - DefaultWGS84 Datasource `yaml:"defaultWGS84" json:"defaultWGS84" validate:"required"` + DefaultWGS84 Datasource `yaml:"defaultWGS84" json:"defaultWGS84" validate:"required"` //nolint:tagliatelle // grandfathered // One or more additional datasources for features in other projections. GoKoala doesn't do // any on-the-fly reprojection so additional datasources need to be reprojected ahead of time. diff --git a/config/ogcapi_styles.go b/config/ogcapi_styles.go index 8e7bc90a..5c0b2199 100644 --- a/config/ogcapi_styles.go +++ b/config/ogcapi_styles.go @@ -15,7 +15,8 @@ type OgcAPIStyles struct { // +kubebuilder:object:generate=true type Style struct { // Unique ID of this style - ID string `yaml:"id" json:"id" validate:"required"` + // +kubebuilder:validation:Pattern=`^[a-z0-9"]([a-z0-9_-]*[a-z0-9"]+|)$` + ID string `yaml:"id" json:"id" validate:"required,lowercase_id"` // Human-friendly name of this style Title string `yaml:"title" json:"title" validate:"required"` diff --git a/config/ogcapi_tiles.go b/config/ogcapi_tiles.go index d62d4817..990464e7 100644 --- a/config/ogcapi_tiles.go +++ b/config/ogcapi_tiles.go @@ -1,6 +1,7 @@ package config import ( + "fmt" "slices" "sort" @@ -18,6 +19,19 @@ type OgcAPITiles struct { Collections GeoSpatialCollections `yaml:"collections,omitempty" json:"collections,omitempty"` } +func (o *OgcAPITiles) Defaults() { + if o.DatasetTiles != nil && o.DatasetTiles.HealthCheck.Srs == DefaultSrs && + o.DatasetTiles.HealthCheck.TilePath == nil { + o.DatasetTiles.deriveHealthCheckTilePath() + } else if o.Collections != nil { + for _, coll := range o.Collections { + if coll.Tiles.GeoDataTiles.HealthCheck.Srs == DefaultSrs && coll.Tiles.GeoDataTiles.HealthCheck.TilePath == nil { + coll.Tiles.GeoDataTiles.deriveHealthCheckTilePath() + } + } + } +} + // +kubebuilder:object:generate=true type CollectionEntryTiles struct { @@ -112,6 +126,19 @@ type Tiles struct { HealthCheck HealthCheck `yaml:"healthCheck" json:"healthCheck"` } +func (t *Tiles) deriveHealthCheckTilePath() { + var deepestZoomLevel int + for _, srs := range t.SupportedSrs { + if srs.Srs == DefaultSrs { + deepestZoomLevel = srs.ZoomLevelRange.End + } + } + defaultTile := HealthCheckDefaultTiles[deepestZoomLevel] + tileMatrixSet := AllTileProjections[DefaultSrs] + tilePath := fmt.Sprintf("/%s/%d/%d/%d.pbf", tileMatrixSet, deepestZoomLevel, defaultTile.x, defaultTile.y) + t.HealthCheck.TilePath = &tilePath +} + // +kubebuilder:object:generate=true type SupportedSrs struct { // Projection (SRS/CRS) used diff --git a/config/url_test.go b/config/url_test.go index 91d6f6b6..94a661bd 100644 --- a/config/url_test.go +++ b/config/url_test.go @@ -11,7 +11,7 @@ import ( ) type TestEmbeddedURL struct { - U URL `json:"U" yaml:"U"` + U URL `json:"u" yaml:"u"` } func TestURL_DeepCopy(t *testing.T) { @@ -174,7 +174,7 @@ func TestURL_Unmarshalling_YAML(t *testing.T) { // non-pointer unmarshalledEmbedded := &TestEmbeddedURL{} - err = yaml.Unmarshal([]byte(`{"U": `+tt.url+`}`), unmarshalledEmbedded) + err = yaml.Unmarshal([]byte(`{"u": `+tt.url+`}`), unmarshalledEmbedded) if !tt.wantErr(t, err, errors.New("yaml.Unmarshal")) { return } diff --git a/examples/README.md b/examples/README.md index dbb60acd..9ff20209 100644 --- a/examples/README.md +++ b/examples/README.md @@ -32,7 +32,7 @@ This example uses 3D tiles of New York. - Start GoKoala as specified in the root [README](../README.md#run) and provide `config_3d.yaml` as the config file. - Open http://localhost:8080 to explore the landing page -- Call http://localhost:8080/collections/NewYork/3dtiles/6/0/1.b3dm to download a specific 3D tile +- Call http://localhost:8080/collections/newyork/3dtiles/6/0/1.b3dm to download a specific 3D tile ## OGC API All/Complete example diff --git a/examples/config_3d.yaml b/examples/config_3d.yaml index fa024e0c..3691c23a 100644 --- a/examples/config_3d.yaml +++ b/examples/config_3d.yaml @@ -1,6 +1,6 @@ --- version: 1.0.0 -title: New York in 3D +title: Example 3D # shortened title, used in breadcrumb path serviceIdentifier: 3D abstract: >- @@ -23,16 +23,7 @@ availableLanguages: - en ogcApi: 3dgeovolumes: - tileServer: https://maps.ecere.com/3DAPI/collections/ + tileServer: https://api.pdok.nl/kadaster/3d-basisvoorziening/ogc/v1/collections collections: - - id: NewYork - # optional basepath to 3D tiles on the tileserver. Defaults to the collection ID. - tileServerPath: "NewYork/3DTiles" - # URI template for individual 3D tiles - uriTemplate3dTiles: "3DTiles/{level}/{x}/{y}.b3m" - - # optional URI template for subtrees, only required when "implicit tiling" extension is used - # uriTemplateImplicitTilingSubtree: "" - - # URI template for digital terrain model (DTM) in Quantized Mesh format, REQUIRED when you want to serve a DTM - # uriTemplateDTM: "" + - id: gebouwen + uriTemplate3dTiles: "t/{level}/{x}/{y}.glb" diff --git a/examples/config_all.yaml b/examples/config_all.yaml index 811c62f4..978cad6a 100644 --- a/examples/config_all.yaml +++ b/examples/config_all.yaml @@ -102,12 +102,11 @@ ogcApi: tableName: addresses 3dgeovolumes: - tileServer: https://maps.ecere.com/3DAPI/collections/ + tileServer: https://api.pdok.nl/kadaster/3d-basisvoorziening/ogc/v1/collections collections: - id: addresses # same collection as the tiles/features - metadata: *addressMetadata - tileServerPath: "NewYork/3DTiles" - uriTemplate3dTiles: "3DTiles/{level}/{x}/{y}.b3m" + tileServerPath: "gebouwen" + uriTemplate3dTiles: "t/{level}/{x}/{y}.glb" styles: default: dummy-style diff --git a/internal/engine/templatefuncs.go b/internal/engine/templatefuncs.go index 6512b7f7..a23d22eb 100644 --- a/internal/engine/templatefuncs.go +++ b/internal/engine/templatefuncs.go @@ -26,7 +26,7 @@ var ( // Initialize functions to be used in html/json/etc templates func init() { customFuncs := texttemplate.FuncMap{ - // custom template functions + // custom template functions (keep lowercase) "markdown": markdown, "unmarkdown": unmarkdown, "truncate": truncateText, diff --git a/internal/engine/templates/openapi/features.go.json b/internal/engine/templates/openapi/features.go.json index 7c54c391..2deea614 100644 --- a/internal/engine/templates/openapi/features.go.json +++ b/internal/engine/templates/openapi/features.go.json @@ -550,7 +550,12 @@ ] }, "geometry": { - "$ref": "#/components/schemas/geometryGeoJSON" + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/geometryGeoJSON" + } + ] }, "properties": { "type": "object", diff --git a/internal/engine/testdata/config_collections_order_alphabetic.yaml b/internal/engine/testdata/config_collections_order_alphabetic.yaml index 1dd375c9..6421682c 100644 --- a/internal/engine/testdata/config_collections_order_alphabetic.yaml +++ b/internal/engine/testdata/config_collections_order_alphabetic.yaml @@ -12,12 +12,12 @@ ogcApi: 3dgeovolumes: tileServer: https://example.com collections: - - id: B - - id: Z - - id: C + - id: b + - id: z + - id: c tiles: collections: - - id: Z + - id: z tileServer: https://example.com - - id: A + - id: a tileServer: https://example.com diff --git a/internal/engine/testdata/config_collections_order_alphabetic_titles.yaml b/internal/engine/testdata/config_collections_order_alphabetic_titles.yaml index a7ef7168..9846587d 100644 --- a/internal/engine/testdata/config_collections_order_alphabetic_titles.yaml +++ b/internal/engine/testdata/config_collections_order_alphabetic_titles.yaml @@ -12,22 +12,22 @@ ogcApi: 3dgeovolumes: tileServer: https://example.com collections: - - id: B + - id: b metadata: title: Bear - - id: Z + - id: z metadata: title: Chicken - - id: C + - id: c metadata: title: Bird tiles: collections: - - id: Z + - id: z tileServer: https://example.com metadata: title: Chicken - - id: A + - id: a tileServer: https://example.com metadata: title: Horse diff --git a/internal/engine/testdata/config_collections_order_literal.yaml b/internal/engine/testdata/config_collections_order_literal.yaml index 958852fd..22181b53 100644 --- a/internal/engine/testdata/config_collections_order_literal.yaml +++ b/internal/engine/testdata/config_collections_order_literal.yaml @@ -9,20 +9,20 @@ license: url: https://creativecommons.org/publicdomain/zero/1.0/deed.nl baseUrl: http://localhost:8181 collectionOrder: - - Z - - C - - A - - B + - z + - c + - a + - b ogcApi: 3dgeovolumes: tileServer: https://example.com collections: - - id: B - - id: Z - - id: C + - id: b + - id: z + - id: c tiles: collections: - - id: Z + - id: z tileServer: https://example.com - - id: A + - id: a tileServer: https://example.com diff --git a/internal/engine/testdata/config_collections_unique.yaml b/internal/engine/testdata/config_collections_unique.yaml index 004b1368..3caa8f7f 100644 --- a/internal/engine/testdata/config_collections_unique.yaml +++ b/internal/engine/testdata/config_collections_unique.yaml @@ -12,10 +12,10 @@ ogcApi: 3dgeovolumes: tileServer: https://example.com collections: - - id: FooCollection + - id: foo_collection tiles: collections: - - id: BarCollection + - id: bar_collection tileServer: https://example.com - - id: FooCollection + - id: foo_collection tileServer: https://example.com diff --git a/internal/engine/testdata/config_invalid_collection_ids.yaml b/internal/engine/testdata/config_invalid_collection_ids.yaml new file mode 100644 index 00000000..2eb71fcd --- /dev/null +++ b/internal/engine/testdata/config_invalid_collection_ids.yaml @@ -0,0 +1,14 @@ +--- +version: 1.0.0 +title: Invalid config file +abstract: Invalid collection IDs +baseUrl: http://test.example +serviceIdentifier: Min +license: + name: MIT + url: https://www.tldrlegal.com/license/mit-license +ogcApi: + features: + collections: + - id: InvalidWithCapital + - id: Invalid With Spaces diff --git a/internal/engine/testdata/config_multiple_ogc_apis_single_collection.yaml b/internal/engine/testdata/config_multiple_ogc_apis_single_collection.yaml index 65d1d66a..5793d454 100644 --- a/internal/engine/testdata/config_multiple_ogc_apis_single_collection.yaml +++ b/internal/engine/testdata/config_multiple_ogc_apis_single_collection.yaml @@ -30,7 +30,7 @@ ogcApi: 3dgeovolumes: tileServer: https://maps.ecere.com/3DAPI/collections/ collections: - - id: NewYork + - id: newyork # reference to common metadata metadata: *collectionMetadata tileServerPath: "NewYork/3DTiles" @@ -48,7 +48,7 @@ ogcApi: local: file: ./examples/resources/addresses-rd.gpkg collections: - - id: NewYork + - id: newyork tableName: addresses # reference to common metadata metadata: *collectionMetadata diff --git a/internal/engine/testdata/config_valid_collection_ids.yaml b/internal/engine/testdata/config_valid_collection_ids.yaml new file mode 100644 index 00000000..399d27be --- /dev/null +++ b/internal/engine/testdata/config_valid_collection_ids.yaml @@ -0,0 +1,16 @@ +--- +version: 1.0.0 +title: Valid config file +abstract: Valid collection IDs +baseUrl: http://test.example +serviceIdentifier: Min +license: + name: MIT + url: https://www.tldrlegal.com/license/mit-license +ogcApi: + features: + collections: + - id: validlowercase + - id: valid-lower-case-with-hyphens + - id: valid_lower_case_with_underscores + - id: "valid_lower-case-with_quotes" # most often used for style ID's diff --git a/internal/engine/testdata/expected_multiple_ogc_apis_single_collection.html b/internal/engine/testdata/expected_multiple_ogc_apis_single_collection.html index 2fb03bdb..3a71e14a 100644 --- a/internal/engine/testdata/expected_multiple_ogc_apis_single_collection.html +++ b/internal/engine/testdata/expected_multiple_ogc_apis_single_collection.html @@ -5,7 +5,7 @@

@@ -14,11 +14,11 @@

-Features +Features

-Blader door de Features of ga direct naar de features in: +Blader door de Features of ga direct naar de features in:

diff --git a/internal/engine/testdata/expected_multiple_ogc_apis_single_collection.json b/internal/engine/testdata/expected_multiple_ogc_apis_single_collection.json index 71cc9c11..3f832c99 100644 --- a/internal/engine/testdata/expected_multiple_ogc_apis_single_collection.json +++ b/internal/engine/testdata/expected_multiple_ogc_apis_single_collection.json @@ -1,6 +1,6 @@ { - "id": "NewYork", - "title": "NewYork", + "id": "newyork", + "title": "newyork", "description": "This is a description about the NewYork collection in Markdown. We offer both 3D Tiles and Features for this collection.", "keywords": [ { @@ -44,52 +44,52 @@ "type": "application/json", "title": "This document as JSON", "updated": "2023-05-10T12:00:00Z", - "href": "http://localhost:8180/collections/NewYork?f=json" + "href": "http://localhost:8180/collections/newyork?f=json" }, { "rel": "alternate", "type": "text/html", "title": "This document as HTML", "updated": "2023-05-10T12:00:00Z", - "href": "http://localhost:8180/collections/NewYork?f=html" + "href": "http://localhost:8180/collections/newyork?f=html" }, { "rel": "preview", "type": "image/png", - "title": "Thumbnail for NewYork", + "title": "Thumbnail for newyork", "href": "http://localhost:8180/resources/3d.png" }, { "rel": "items", "type": "application/json+3dtiles", - "title": "Tileset definition of collection NewYork according to the OGC 3D Tiles specification", - "href": "http://localhost:8180/collections/NewYork/3dtiles?f=json" + "title": "Tileset definition of collection newyork according to the OGC 3D Tiles specification", + "href": "http://localhost:8180/collections/newyork/3dtiles?f=json" }, { "rel": "items", "type": "application/geo+json", - "title": "The JSON representation of the NewYork features served from this endpoint", - "href": "http://localhost:8180/collections/NewYork/items?f=json" + "title": "The JSON representation of the newyork features served from this endpoint", + "href": "http://localhost:8180/collections/newyork/items?f=json" }, { "rel": "items", "type": "application/vnd.ogc.fg+json", - "title": "The JSON-FG representation of the NewYork features served from this endpoint", - "href": "http://localhost:8180/collections/NewYork/items?f=jsonfg" + "title": "The JSON-FG representation of the newyork features served from this endpoint", + "href": "http://localhost:8180/collections/newyork/items?f=jsonfg" }, { "rel": "items", "type": "text/html", - "title": "The HTML representation of the NewYork features served from this endpoint", - "href": "http://localhost:8180/collections/NewYork/items?f=html" + "title": "The HTML representation of the newyork features served from this endpoint", + "href": "http://localhost:8180/collections/newyork/items?f=html" } ], "content": [ { "rel": "original", "type": "application/json+3dtiles", - "title": "Tileset definition of collection NewYork according to the OGC 3D Tiles specification", - "href": "http://localhost:8180/collections/NewYork/3dtiles?f=json", + "title": "Tileset definition of collection newyork according to the OGC 3D Tiles specification", + "href": "http://localhost:8180/collections/newyork/3dtiles?f=json", "collectionType": "3d-container" } ] diff --git a/internal/engine/testdata/expected_multiple_ogc_apis_single_collection_json_ld.html b/internal/engine/testdata/expected_multiple_ogc_apis_single_collection_json_ld.html index 6a8efee1..eb1e4049 100644 --- a/internal/engine/testdata/expected_multiple_ogc_apis_single_collection_json_ld.html +++ b/internal/engine/testdata/expected_multiple_ogc_apis_single_collection_json_ld.html @@ -5,7 +5,7 @@ "isPartOf": "http:\/\/localhost:8180?f=html", "name": "New York - NewYork", "description": "This is a description about the NewYork collection in Markdown. We offer both 3D Tiles and Features for this collection.", - "url": "http:\/\/localhost:8180/collections/NewYork?f=html","keywords": ["Keyword1","Keyword2"],"license": "https:\/\/creativecommons.org\/publicdomain\/zero\/1.0\/deed.nl", + "url": "http:\/\/localhost:8180/collections/newyork?f=html","keywords": ["Keyword1","Keyword2"],"license": "https:\/\/creativecommons.org\/publicdomain\/zero\/1.0\/deed.nl", "isAccessibleForFree": true ,"thumbnailUrl": "http:\/\/localhost:8180/resources/3d.png" ,"version": "2023-05-10" diff --git a/internal/ogc/features/datasources/geopackage/geopackage.go b/internal/ogc/features/datasources/geopackage/geopackage.go index 51f1b97c..00296863 100644 --- a/internal/ogc/features/datasources/geopackage/geopackage.go +++ b/internal/ogc/features/datasources/geopackage/geopackage.go @@ -18,14 +18,13 @@ import ( "github.com/PDOK/gokoala/internal/ogc/features/datasources" "github.com/PDOK/gokoala/internal/ogc/features/domain" "github.com/go-spatial/geom" + "github.com/go-spatial/geom/cmp" "github.com/go-spatial/geom/encoding/gpkg" "github.com/go-spatial/geom/encoding/wkt" "github.com/google/uuid" "github.com/jmoiron/sqlx" "github.com/mattn/go-sqlite3" "github.com/qustavo/sqlhooks/v2" - - _ "github.com/mattn/go-sqlite3" // import for side effect (= sqlite3 driver) only ) const ( @@ -47,6 +46,7 @@ func loadDriver() { }) } +// geoPackageBackend abstraction over different kinds of GeoPackages, e.g. local file or cloud-backed sqlite. type geoPackageBackend interface { getDB() *sqlx.DB close() @@ -447,11 +447,14 @@ func (g *GeoPackage) selectSpecificColumnsInOrder(propConfig *config.FeatureProp } func mapGpkgGeometry(rawGeom []byte) (geom.Geometry, error) { - geometry, err := gpkg.DecodeGeometry(rawGeom) + geomWithMetadata, err := gpkg.DecodeGeometry(rawGeom) if err != nil { return nil, err } - return geometry.Geometry, nil + if geomWithMetadata == nil || cmp.IsEmptyGeo(geomWithMetadata.Geometry) { + return nil, nil + } + return geomWithMetadata.Geometry, nil } func propertyFiltersToSQL(pf map[string]string) (sql string, namedParams map[string]any) { diff --git a/internal/ogc/features/datasources/geopackage/geopackage_test.go b/internal/ogc/features/datasources/geopackage/geopackage_test.go index 897d65e6..30b25ba0 100644 --- a/internal/ogc/features/datasources/geopackage/geopackage_test.go +++ b/internal/ogc/features/datasources/geopackage/geopackage_test.go @@ -22,7 +22,7 @@ func init() { pwd = path.Dir(filename) } -func newAddressesGeoPackage() geoPackageBackend { +func newTestGeoPackage(file string) geoPackageBackend { loadDriver() return newLocalGeoPackage(&config.GeoPackageLocal{ GeoPackageCommon: config.GeoPackageCommon{ @@ -31,20 +31,7 @@ func newAddressesGeoPackage() geoPackageBackend { MaxBBoxSizeToUseWithRTree: 30000, InMemoryCacheSize: -2000, }, - File: pwd + "/testdata/bag.gpkg", - }) -} - -func newTemporalAddressesGeoPackage() geoPackageBackend { - loadDriver() - return newLocalGeoPackage(&config.GeoPackageLocal{ - GeoPackageCommon: config.GeoPackageCommon{ - Fid: "feature_id", - QueryTimeout: config.Duration{Duration: 15 * time.Second}, - MaxBBoxSizeToUseWithRTree: 30000, - InMemoryCacheSize: -2000, - }, - File: pwd + "/testdata/bag-temporal.gpkg", + File: pwd + file, }) } @@ -107,11 +94,12 @@ func TestGeoPackage_GetFeatures(t *testing.T) { wantFC *domain.FeatureCollection wantCursor domain.Cursors wantErr bool + wantGeom bool }{ { name: "get first page of features", fields: fields{ - backend: newAddressesGeoPackage(), + backend: newTestGeoPackage("/testdata/bag.gpkg"), fidColumn: "feature_id", featureTableByID: map[string]*featureTable{"ligplaatsen": {TableName: "ligplaatsen", GeometryColumnName: "geom"}}, queryTimeout: 60 * time.Second, @@ -145,12 +133,13 @@ func TestGeoPackage_GetFeatures(t *testing.T) { Prev: "|", Next: "Dv4|", // 3838 }, - wantErr: false, + wantGeom: true, + wantErr: false, }, { name: "get second page of features", fields: fields{ - backend: newAddressesGeoPackage(), + backend: newTestGeoPackage("/testdata/bag.gpkg"), fidColumn: "feature_id", featureTableByID: map[string]*featureTable{"ligplaatsen": {TableName: "ligplaatsen", GeometryColumnName: "geom"}}, queryTimeout: 5 * time.Second, @@ -194,12 +183,13 @@ func TestGeoPackage_GetFeatures(t *testing.T) { Prev: "|", Next: "DwE|", }, - wantErr: false, + wantGeom: true, + wantErr: false, }, { name: "get first page of features with reference date", fields: fields{ - backend: newTemporalAddressesGeoPackage(), + backend: newTestGeoPackage("/testdata/bag-temporal.gpkg"), fidColumn: "feature_id", featureTableByID: map[string]*featureTable{"ligplaatsen": {TableName: "ligplaatsen", GeometryColumnName: "geom"}}, queryTimeout: 60 * time.Second, @@ -238,12 +228,13 @@ func TestGeoPackage_GetFeatures(t *testing.T) { Prev: "|", Next: "Dv4|", // 3838 }, - wantErr: false, + wantGeom: true, + wantErr: false, }, { name: "fail on non existing collection", fields: fields{ - backend: newAddressesGeoPackage(), + backend: newTestGeoPackage("/testdata/bag.gpkg"), fidColumn: "feature_id", featureTableByID: map[string]*featureTable{"ligplaatsen": {TableName: "ligplaatsen", GeometryColumnName: "geom"}}, queryTimeout: 5 * time.Second, @@ -260,6 +251,74 @@ func TestGeoPackage_GetFeatures(t *testing.T) { wantCursor: domain.Cursors{}, wantErr: true, // should fail }, + { + name: "get features with empty geometry", + fields: fields{ + backend: newTestGeoPackage("/testdata/null-empty-geoms.gpkg"), + fidColumn: "feature_id", + featureTableByID: map[string]*featureTable{"ligplaatsen": {TableName: "ligplaatsen", GeometryColumnName: "geom"}}, + queryTimeout: 60 * time.Second, + }, + args: args{ + ctx: context.Background(), + collection: "ligplaatsen", + queryParams: datasources.FeaturesCriteria{ + Cursor: domain.DecodedCursor{FID: 0, FiltersChecksum: []byte{}}, + Limit: 1, + }, + }, + wantFC: &domain.FeatureCollection{ + NumberReturned: 1, + Features: []*domain.Feature{ + { + Properties: domain.NewFeaturePropertiesWithData(false, map[string]any{ + "straatnaam": "Van Diemenkade", + "nummer_id": "0363200000454013", + }), + }, + }, + }, + wantCursor: domain.Cursors{ + Prev: "|", + Next: "GSQ|", + }, + wantGeom: false, // should be null + wantErr: false, + }, + { + name: "get features with null geometry", + fields: fields{ + backend: newTestGeoPackage("/testdata/null-empty-geoms.gpkg"), + fidColumn: "feature_id", + featureTableByID: map[string]*featureTable{"ligplaatsen": {TableName: "ligplaatsen", GeometryColumnName: "geom"}}, + queryTimeout: 60 * time.Second, + }, + args: args{ + ctx: context.Background(), + collection: "ligplaatsen", + queryParams: datasources.FeaturesCriteria{ + Cursor: domain.DecodedCursor{FID: 6436, FiltersChecksum: []byte{}}, + Limit: 1, + }, + }, + wantFC: &domain.FeatureCollection{ + NumberReturned: 1, + Features: []*domain.Feature{ + { + Properties: domain.NewFeaturePropertiesWithData(false, map[string]any{ + "straatnaam": "Bokkinghangen", + "nummer_id": "0363200012163629", + }), + }, + }, + }, + wantCursor: domain.Cursors{ + Prev: "DdY|", + Next: "|", + }, + wantGeom: false, // should be null + wantErr: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -284,6 +343,9 @@ func TestGeoPackage_GetFeatures(t *testing.T) { for i, wantedFeature := range tt.wantFC.Features { assert.Equal(t, wantedFeature.Properties.Value("straatnaam"), fc.Features[i].Properties.Value("straatnaam")) assert.Equal(t, wantedFeature.Properties.Value("nummer_id"), fc.Features[i].Properties.Value("nummer_id")) + if !tt.wantGeom { + assert.Nil(t, fc.Features[i].Geometry) + } } assert.Equal(t, tt.wantCursor.Prev, cursor.Prev) assert.Equal(t, tt.wantCursor.Next, cursor.Next) @@ -313,7 +375,7 @@ func TestGeoPackage_GetFeature(t *testing.T) { { name: "get feature", fields: fields{ - backend: newAddressesGeoPackage(), + backend: newTestGeoPackage("/testdata/bag.gpkg"), fidColumn: "feature_id", featureTableByID: map[string]*featureTable{"ligplaatsen": {TableName: "ligplaatsen", GeometryColumnName: "geom"}}, queryTimeout: 5 * time.Second, @@ -336,7 +398,7 @@ func TestGeoPackage_GetFeature(t *testing.T) { { name: "get non existing feature", fields: fields{ - backend: newAddressesGeoPackage(), + backend: newTestGeoPackage("/testdata/bag.gpkg"), fidColumn: "feature_id", featureTableByID: map[string]*featureTable{"ligplaatsen": {TableName: "ligplaatsen", GeometryColumnName: "geom"}}, queryTimeout: 5 * time.Second, @@ -352,7 +414,7 @@ func TestGeoPackage_GetFeature(t *testing.T) { { name: "fail on non existing collection", fields: fields{ - backend: newAddressesGeoPackage(), + backend: newTestGeoPackage("/testdata/bag.gpkg"), fidColumn: "feature_id", featureTableByID: map[string]*featureTable{"ligplaatsen": {TableName: "ligplaatsen", GeometryColumnName: "geom"}}, queryTimeout: 5 * time.Second, @@ -394,7 +456,7 @@ func TestGeoPackage_GetFeature(t *testing.T) { func TestGeoPackage_Warmup(t *testing.T) { t.Run("warmup", func(t *testing.T) { g := &GeoPackage{ - backend: newAddressesGeoPackage(), + backend: newTestGeoPackage("/testdata/bag.gpkg"), fidColumn: "feature_id", featureTableByCollectionID: map[string]*featureTable{"ligplaatsen": {TableName: "ligplaatsen", GeometryColumnName: "geom"}}, queryTimeout: 5 * time.Second, diff --git a/internal/ogc/features/datasources/geopackage/testdata/bag-null-empty.gpkg b/internal/ogc/features/datasources/geopackage/testdata/bag-null-empty.gpkg new file mode 100644 index 00000000..e69de29b diff --git a/internal/ogc/features/datasources/geopackage/testdata/null-empty-geoms.gpkg b/internal/ogc/features/datasources/geopackage/testdata/null-empty-geoms.gpkg new file mode 100644 index 00000000..302b4418 Binary files /dev/null and b/internal/ogc/features/datasources/geopackage/testdata/null-empty-geoms.gpkg differ diff --git a/internal/ogc/features/domain/cursor_test.go b/internal/ogc/features/domain/cursor_test.go index a9b5ed88..c0241d47 100644 --- a/internal/ogc/features/domain/cursor_test.go +++ b/internal/ogc/features/domain/cursor_test.go @@ -151,6 +151,14 @@ func TestEncodedCursor_Decode(t *testing.T) { FiltersChecksum: []byte("foobar"), }, }, + { + name: "should decode cursor without checksum", + c: "GSQ|", + want: DecodedCursor{ + FID: 6436, + FiltersChecksum: nil, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/ogc/features/domain/mapper.go b/internal/ogc/features/domain/mapper.go index 167aea32..bfef6c0b 100644 --- a/internal/ogc/features/domain/mapper.go +++ b/internal/ogc/features/domain/mapper.go @@ -7,7 +7,6 @@ import ( "time" "github.com/PDOK/gokoala/config" - "github.com/go-spatial/geom" "github.com/go-spatial/geom/encoding/geojson" "github.com/jmoiron/sqlx" @@ -109,7 +108,9 @@ func mapColumnsToFeature(ctx context.Context, firstRow bool, feature *Feature, c if err != nil { return nil, fmt.Errorf("failed to map/decode geometry from datasource, error: %w", err) } - feature.Geometry = geojson.Geometry{Geometry: mappedGeom} + if mappedGeom != nil { + feature.Geometry = geojson.Geometry{Geometry: mappedGeom} + } case "minx", "miny", "maxx", "maxy", "min_zoom", "max_zoom": // Skip these columns used for bounding box and zoom filtering diff --git a/internal/ogc/features/main_test.go b/internal/ogc/features/main_test.go index 3520f393..f8e14e33 100644 --- a/internal/ogc/features/main_test.go +++ b/internal/ogc/features/main_test.go @@ -1009,6 +1009,62 @@ func TestFeatures_Feature(t *testing.T) { statusCode: http.StatusOK, }, }, + { + name: "Request GeoJSON for feature with null geom", + fields: fields{ + configFile: "internal/ogc/features/testdata/config_features_geom_null_empty.yaml", + url: "http://localhost:8080/collections/:collectionId/items/:featureId", + collectionID: "foo", + featureID: "6436", + format: "json", + }, + want: want{ + body: "internal/ogc/features/testdata/expected_feature_geom_null.json", + statusCode: http.StatusOK, + }, + }, + { + name: "Request JSON-FG for feature with null geom", + fields: fields{ + configFile: "internal/ogc/features/testdata/config_features_geom_null_empty.yaml", + url: "http://localhost:8080/collections/:collectionId/items/:featureId?f=jsonfg", + collectionID: "foo", + featureID: "6436", + format: "json", + }, + want: want{ + body: "internal/ogc/features/testdata/expected_feature_geom_null_jsonfg.json", + statusCode: http.StatusOK, + }, + }, + { + name: "Request GeoJSON for feature with empty point", + fields: fields{ + configFile: "internal/ogc/features/testdata/config_features_geom_null_empty.yaml", + url: "http://localhost:8080/collections/:collectionId/items/:featureId", + collectionID: "foo", + featureID: "3542", + format: "json", + }, + want: want{ + body: "internal/ogc/features/testdata/expected_feature_geom_empty_point.json", + statusCode: http.StatusOK, + }, + }, + { + name: "Request JSON-FG for feature with empty point", + fields: fields{ + configFile: "internal/ogc/features/testdata/config_features_geom_null_empty.yaml", + url: "http://localhost:8080/collections/:collectionId/items/:featureId?f=jsonfg", + collectionID: "foo", + featureID: "3542", + format: "json", + }, + want: want{ + body: "internal/ogc/features/testdata/expected_feature_geom_empty_point_jsonfg.json", + statusCode: http.StatusOK, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -1031,9 +1087,7 @@ func TestFeatures_Feature(t *testing.T) { assert.Equal(t, tt.want.statusCode, rr.Code) if tt.want.body != "" { expectedBody, err := os.ReadFile(tt.want.body) - if err != nil { - log.Fatal(err) - } + assert.NoError(t, err) printActual(rr) switch { diff --git a/internal/ogc/features/testdata/config_features_geom_null_empty.yaml b/internal/ogc/features/testdata/config_features_geom_null_empty.yaml new file mode 100644 index 00000000..8e1fa729 --- /dev/null +++ b/internal/ogc/features/testdata/config_features_geom_null_empty.yaml @@ -0,0 +1,35 @@ +--- +version: 1.0.2 +title: OGC API Features +abstract: Contains a slimmed-down/example version of the BAG-dataset +baseUrl: http://localhost:8080 +serviceIdentifier: Feats +license: + name: CC0 + url: https://www.tldrlegal.com/license/creative-commons-cc0-1-0-universal +ogcApi: + features: + datasources: + defaultWGS84: + geopackage: + local: + file: ./internal/ogc/features/datasources/geopackage/testdata/null-empty-geoms.gpkg + fid: feature_id + queryTimeout: 15m # pretty high to allow debugging + collections: + - id: foo + tableName: ligplaatsen + filters: + properties: + - name: straatnaam + - name: postcode + metadata: + title: Foooo + description: Foooo + - id: bar + tableName: ligplaatsen + metadata: + title: Barrr + description: Barrr + tableName: ligplaatsen + - id: baz diff --git a/internal/ogc/features/testdata/expected_feature_geom_empty_point.json b/internal/ogc/features/testdata/expected_feature_geom_empty_point.json new file mode 100644 index 00000000..a18937af --- /dev/null +++ b/internal/ogc/features/testdata/expected_feature_geom_empty_point.json @@ -0,0 +1,47 @@ +{ + "type": "Feature", + "properties": { + "datum_doc": "1900-01-01", + "datum_eind": null, + "datum_strt": "1900-01-01", + "document": "GV00000402", + "huisletter": null, + "huisnummer": 14, + "nummer_id": "0363200000454013", + "postcode": "1013CR", + "rdf_seealso": "http://bag.basisregistraties.overheid.nl/bag/id/nummeraanduiding/0363200000454013", + "status": "Naamgeving uitgegeven", + "straatnaam": "Van Diemenkade", + "toevoeging": null, + "type": "Ligplaats", + "woonplaats": "Amsterdam" + }, + "geometry": null, + "id": "3542", + "links": [ + { + "rel": "self", + "title": "This document as GeoJSON", + "type": "application/geo+json", + "href": "http://localhost:8080/collections/foo/items/3542?f=json" + }, + { + "rel": "alternate", + "title": "This document as JSON-FG", + "type": "application/vnd.ogc.fg+json", + "href": "http://localhost:8080/collections/foo/items/3542?f=jsonfg" + }, + { + "rel": "alternate", + "title": "This document as HTML", + "type": "text/html", + "href": "http://localhost:8080/collections/foo/items/3542?f=html" + }, + { + "rel": "collection", + "title": "The collection to which this feature belongs", + "type": "application/json", + "href": "http://localhost:8080/collections/foo?f=json" + } + ] +} diff --git a/internal/ogc/features/testdata/expected_feature_geom_empty_point_jsonfg.json b/internal/ogc/features/testdata/expected_feature_geom_empty_point_jsonfg.json new file mode 100644 index 00000000..258307ca --- /dev/null +++ b/internal/ogc/features/testdata/expected_feature_geom_empty_point_jsonfg.json @@ -0,0 +1,53 @@ +{ + "id": "3542", + "type": "Feature", + "time": null, + "place": null, + "geometry": null, + "properties": { + "datum_doc": "1900-01-01", + "datum_eind": null, + "datum_strt": "1900-01-01", + "document": "GV00000402", + "huisletter": null, + "huisnummer": 14, + "nummer_id": "0363200000454013", + "postcode": "1013CR", + "rdf_seealso": "http://bag.basisregistraties.overheid.nl/bag/id/nummeraanduiding/0363200000454013", + "status": "Naamgeving uitgegeven", + "straatnaam": "Van Diemenkade", + "toevoeging": null, + "type": "Ligplaats", + "woonplaats": "Amsterdam" + }, + "coordRefSys": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + "links": [ + { + "rel": "self", + "title": "This document as JSON-FG", + "type": "application/vnd.ogc.fg+json", + "href": "http://localhost:8080/collections/foo/items/3542?f=jsonfg" + }, + { + "rel": "alternate", + "title": "This document as GeoJSON", + "type": "application/geo+json", + "href": "http://localhost:8080/collections/foo/items/3542?f=json" + }, + { + "rel": "alternate", + "title": "This document as HTML", + "type": "text/html", + "href": "http://localhost:8080/collections/foo/items/3542?f=html" + }, + { + "rel": "collection", + "title": "The collection to which this feature belongs", + "type": "application/json", + "href": "http://localhost:8080/collections/foo?f=json" + } + ], + "conformsTo": [ + "http://www.opengis.net/spec/json-fg-1/0.2/conf/core" + ] +} diff --git a/internal/ogc/features/testdata/expected_feature_geom_null.json b/internal/ogc/features/testdata/expected_feature_geom_null.json new file mode 100644 index 00000000..b7ac3338 --- /dev/null +++ b/internal/ogc/features/testdata/expected_feature_geom_null.json @@ -0,0 +1,48 @@ +{ + "type": "Feature", + "properties": { + "datum_doc": "2021-02-26", + "datum_eind": null, + "datum_strt": "2021-02-26", + "document": "SE05427877", + "geom": null, + "huisletter": null, + "huisnummer": 6, + "nummer_id": "0363200012163629", + "postcode": "1013NK", + "rdf_seealso": "http://bag.basisregistraties.overheid.nl/bag/id/nummeraanduiding/0363200012163629", + "status": "Naamgeving uitgegeven", + "straatnaam": "Bokkinghangen", + "toevoeging": null, + "type": "Ligplaats", + "woonplaats": "Amsterdam" + }, + "geometry": null, + "id": "6436", + "links": [ + { + "rel": "self", + "title": "This document as GeoJSON", + "type": "application/geo+json", + "href": "http://localhost:8080/collections/foo/items/6436?f=json" + }, + { + "rel": "alternate", + "title": "This document as JSON-FG", + "type": "application/vnd.ogc.fg+json", + "href": "http://localhost:8080/collections/foo/items/6436?f=jsonfg" + }, + { + "rel": "alternate", + "title": "This document as HTML", + "type": "text/html", + "href": "http://localhost:8080/collections/foo/items/6436?f=html" + }, + { + "rel": "collection", + "title": "The collection to which this feature belongs", + "type": "application/json", + "href": "http://localhost:8080/collections/foo?f=json" + } + ] +} diff --git a/internal/ogc/features/testdata/expected_feature_geom_null_jsonfg.json b/internal/ogc/features/testdata/expected_feature_geom_null_jsonfg.json new file mode 100644 index 00000000..019d7635 --- /dev/null +++ b/internal/ogc/features/testdata/expected_feature_geom_null_jsonfg.json @@ -0,0 +1,54 @@ +{ + "id": "6436", + "type": "Feature", + "time": null, + "place": null, + "geometry": null, + "properties": { + "datum_doc": "2021-02-26", + "datum_eind": null, + "datum_strt": "2021-02-26", + "document": "SE05427877", + "geom": null, + "huisletter": null, + "huisnummer": 6, + "nummer_id": "0363200012163629", + "postcode": "1013NK", + "rdf_seealso": "http://bag.basisregistraties.overheid.nl/bag/id/nummeraanduiding/0363200012163629", + "status": "Naamgeving uitgegeven", + "straatnaam": "Bokkinghangen", + "toevoeging": null, + "type": "Ligplaats", + "woonplaats": "Amsterdam" + }, + "coordRefSys": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + "links": [ + { + "rel": "self", + "title": "This document as JSON-FG", + "type": "application/vnd.ogc.fg+json", + "href": "http://localhost:8080/collections/foo/items/6436?f=jsonfg" + }, + { + "rel": "alternate", + "title": "This document as GeoJSON", + "type": "application/geo+json", + "href": "http://localhost:8080/collections/foo/items/6436?f=json" + }, + { + "rel": "alternate", + "title": "This document as HTML", + "type": "text/html", + "href": "http://localhost:8080/collections/foo/items/6436?f=html" + }, + { + "rel": "collection", + "title": "The collection to which this feature belongs", + "type": "application/json", + "href": "http://localhost:8080/collections/foo?f=json" + } + ], + "conformsTo": [ + "http://www.opengis.net/spec/json-fg-1/0.2/conf/core" + ] +}