Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(config): Add option to specify collection ordering #235

Merged
merged 5 commits into from
Sep 13, 2024
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
7 changes: 7 additions & 0 deletions .github/workflows/test-go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ jobs:
- name: Benchmark
run: go test -v ./... -bench=. -run=^# -benchmem -count=1

- name: Fail when coverage below threshold
uses: vladopajic/go-test-coverage@v2
with:
profile: cover.out
local-prefix: github.com/PDOK/gokoala
threshold-total: 80 # 80% overall coverage is the minimum

- name: Update coverage report
uses: ncruces/go-coverage-report@v0
with:
Expand Down
6 changes: 6 additions & 0 deletions config/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Config

This config package is used to validate and unmarshall a GoKoala YAML config file to 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.
110 changes: 92 additions & 18 deletions config/collections.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,23 @@ type GeoSpatialCollectionMetadata struct {
StorageCrs *string `yaml:"storageCrs,omitempty" json:"storageCrs,omitempty" default:"http://www.opengis.net/def/crs/OGC/1.3/CRS84" validate:"startswith=http://www.opengis.net/def/crs"`
}

// +kubebuilder:object:generate=true
type Extent struct {
// Projection (SRS/CRS) to be used. When none is provided WGS84 (http://www.opengis.net/def/crs/OGC/1.3/CRS84) is used.
// +optional
// +kubebuilder:validation:Pattern=`^EPSG:\d+$`
Srs string `yaml:"srs,omitempty" json:"srs,omitempty" validate:"omitempty,startswith=EPSG:"`

// Geospatial extent
Bbox []string `yaml:"bbox" json:"bbox"`

// Temporal extent
// +optional
// +kubebuilder:validation:MinItems=2
// +kubebuilder:validation:MaxItems=2
Interval []string `yaml:"interval,omitempty" json:"interval,omitempty" validate:"omitempty,len=2"`
}

// +kubebuilder:object:generate=true
type CollectionLinks struct {
// Links to downloads of entire collection. These will be rendered as rel=enclosure links
Expand All @@ -121,30 +138,61 @@ type CollectionLinks struct {
// <placeholder>
}

// Unique lists all unique GeoSpatialCollections (no duplicate IDs),
// return results in alphabetic order
// +kubebuilder:object:generate=true
type DownloadLink struct {
// Name of the provided download
Name string `yaml:"name" json:"name" validate:"required"`

// Full URL to the file to be downloaded
AssetURL *URL `yaml:"assetUrl" json:"assetUrl" validate:"required"`

// Approximate size of the file to be downloaded
// +optional
Size string `yaml:"size,omitempty" json:"size,omitempty"`

// Media type of the file to be downloaded
MediaType MediaType `yaml:"mediaType" json:"mediaType" validate:"required"`
}

// HasCollections does this API offer collections with for example features, tiles, 3d tiles, etc
func (c *Config) HasCollections() bool {
return c.AllCollections() != nil
}

// AllCollections get all collections - with for example features, tiles, 3d tiles - offered through this OGC API.
// Results are returned in alphabetic or literal order.
func (c *Config) AllCollections() GeoSpatialCollections {
var result GeoSpatialCollections
if c.OgcAPI.GeoVolumes != nil {
result = append(result, c.OgcAPI.GeoVolumes.Collections...)
}
if c.OgcAPI.Tiles != nil {
result = append(result, c.OgcAPI.Tiles.Collections...)
}
if c.OgcAPI.Features != nil {
result = append(result, c.OgcAPI.Features.Collections...)
}

// sort
if len(c.OgcAPICollectionOrder) > 0 {
sortByLiteralOrder(result, c.OgcAPICollectionOrder)
} else {
sortByAlphabet(result)
}
return result
}

// Unique lists all unique GeoSpatialCollections (no duplicate IDs)
func (g GeoSpatialCollections) Unique() []GeoSpatialCollection {
collectionsByID := g.toMap()
flattened := make([]GeoSpatialCollection, 0, len(collectionsByID))
result := make([]GeoSpatialCollection, 0, len(collectionsByID))
for _, v := range collectionsByID {
flattened = append(flattened, v)
result = append(result, v)
}
sort.Slice(flattened, func(i, j int) bool {
icomp := flattened[i].ID
jcomp := flattened[j].ID
// prefer to sort by title when available, collection ID otherwise
if flattened[i].Metadata != nil && flattened[i].Metadata.Title != nil {
icomp = *flattened[i].Metadata.Title
}
if flattened[j].Metadata != nil && flattened[j].Metadata.Title != nil {
jcomp = *flattened[j].Metadata.Title
}
return icomp < jcomp
})
return flattened
return result
}

// ContainsID check if given collection - by ID - exists
// ContainsID check if given collection - by ID - exists.
func (g GeoSpatialCollections) ContainsID(id string) bool {
_, ok := g.toMap()[id]
return ok
Expand All @@ -166,3 +214,29 @@ func (g GeoSpatialCollections) toMap() map[string]GeoSpatialCollection {
}
return collectionsByID
}

func sortByAlphabet(collection []GeoSpatialCollection) {
sort.Slice(collection, func(i, j int) bool {
iName := collection[i].ID
jName := collection[j].ID
// prefer to sort by title when available, collection ID otherwise
if collection[i].Metadata != nil && collection[i].Metadata.Title != nil {
iName = *collection[i].Metadata.Title
}
if collection[j].Metadata != nil && collection[j].Metadata.Title != nil {
jName = *collection[j].Metadata.Title
}
return iName < jName
})
}

func sortByLiteralOrder(collections []GeoSpatialCollection, literalOrder []string) {
collectionOrderIndex := make(map[string]int)
for i, id := range literalOrder {
collectionOrderIndex[id] = i
}
sort.Slice(collections, func(i, j int) bool {
// sort according to the explict/literal order specified in OgcAPICollectionOrder
return collectionOrderIndex[collections[i].ID] < collectionOrderIndex[collections[j].ID]
})
}
103 changes: 25 additions & 78 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ type Config struct {
// Define which OGC API building blocks this API supports
OgcAPI OgcAPI `yaml:"ogcApi" json:"ogcApi" validate:"required"`

// Order in which collections (containing features, tiles, 3d tiles, etc.) should be returned.
// When not specified collections are returned in alphabetic order.
// +optional
OgcAPICollectionOrder []string `yaml:"collectionOrder,omitempty" json:"collectionOrder,omitempty"`

// Reference to a PNG image to use a thumbnail on the landing page.
// The full path is constructed by appending Resources + Thumbnail.
// +optional
Expand Down Expand Up @@ -126,22 +131,27 @@ func (c *Config) CookieMaxAge() int {
return CookieMaxAge
}

func (c *Config) HasCollections() bool {
return c.AllCollections() != nil
}
// +kubebuilder:object:generate=true
type OgcAPI struct {
// Enable when this API should offer OGC API 3D GeoVolumes. This includes OGC 3D Tiles.
// +optional
GeoVolumes *OgcAPI3dGeoVolumes `yaml:"3dgeovolumes,omitempty" json:"3dgeovolumes,omitempty"`

func (c *Config) AllCollections() GeoSpatialCollections {
var result GeoSpatialCollections
if c.OgcAPI.GeoVolumes != nil {
result = append(result, c.OgcAPI.GeoVolumes.Collections...)
}
if c.OgcAPI.Tiles != nil {
result = append(result, c.OgcAPI.Tiles.Collections...)
}
if c.OgcAPI.Features != nil {
result = append(result, c.OgcAPI.Features.Collections...)
}
return result
// Enable when this API should offer OGC API Tiles. This also requires OGC API Styles.
// +optional
Tiles *OgcAPITiles `yaml:"tiles,omitempty" json:"tiles,omitempty" validate:"required_with=Styles"`

// Enable when this API should offer OGC API Styles.
// +optional
Styles *OgcAPIStyles `yaml:"styles,omitempty" json:"styles,omitempty"`

// Enable when this API should offer OGC API Features.
// +optional
Features *OgcAPIFeatures `yaml:"features,omitempty" json:"features,omitempty"`

// Enable when this API should offer OGC API Processes.
// +optional
Processes *OgcAPIProcesses `yaml:"processes,omitempty" json:"processes,omitempty"`
}

// +kubebuilder:object:generate=true
Expand Down Expand Up @@ -178,46 +188,6 @@ type Resources struct {
Directory *string `yaml:"directory,omitempty" json:"directory,omitempty" validate:"required_without=URL,omitempty,dirpath|filepath"`
}

// +kubebuilder:object:generate=true
type OgcAPI struct {
// Enable when this API should offer OGC API 3D GeoVolumes. This includes OGC 3D Tiles.
// +optional
GeoVolumes *OgcAPI3dGeoVolumes `yaml:"3dgeovolumes,omitempty" json:"3dgeovolumes,omitempty"`

// Enable when this API should offer OGC API Tiles. This also requires OGC API Styles.
// +optional
Tiles *OgcAPITiles `yaml:"tiles,omitempty" json:"tiles,omitempty" validate:"required_with=Styles"`

// Enable when this API should offer OGC API Styles.
// +optional
Styles *OgcAPIStyles `yaml:"styles,omitempty" json:"styles,omitempty"`

// Enable when this API should offer OGC API Features.
// +optional
Features *OgcAPIFeatures `yaml:"features,omitempty" json:"features,omitempty"`

// Enable when this API should offer OGC API Processes.
// +optional
Processes *OgcAPIProcesses `yaml:"processes,omitempty" json:"processes,omitempty"`
}

// +kubebuilder:object:generate=true
type Extent struct {
// Projection (SRS/CRS) to be used. When none is provided WGS84 (http://www.opengis.net/def/crs/OGC/1.3/CRS84) is used.
// +optional
// +kubebuilder:validation:Pattern=`^EPSG:\d+$`
Srs string `yaml:"srs,omitempty" json:"srs,omitempty" validate:"omitempty,startswith=EPSG:"`

// Geospatial extent
Bbox []string `yaml:"bbox" json:"bbox"`

// Temporal extent
// +optional
// +kubebuilder:validation:MinItems=2
// +kubebuilder:validation:MaxItems=2
Interval []string `yaml:"interval,omitempty" json:"interval,omitempty" validate:"omitempty,len=2"`
}

// +kubebuilder:object:generate=true
type License struct {
// Name of the license, e.g. MIT, CC0, etc
Expand Down Expand Up @@ -266,29 +236,6 @@ func validate(config *Config) error {
return nil
}

func validateFeatureCollections(collections GeoSpatialCollections) error {
var errMessages []string
for _, collection := range collections {
if collection.Metadata != nil && collection.Metadata.TemporalProperties != nil &&
(collection.Metadata.Extent == nil || collection.Metadata.Extent.Interval == nil) {
errMessages = append(errMessages, fmt.Sprintf("validation failed for collection '%s'; "+
"field 'Extent.Interval' is required with field 'TemporalProperties'\n", collection.ID))
}
if collection.Features != nil && collection.Features.Filters.Properties != nil {
for _, pf := range collection.Features.Filters.Properties {
if pf.AllowedValues != nil && *pf.DeriveAllowedValuesFromDatasource {
errMessages = append(errMessages, fmt.Sprintf("validation failed for property filter '%s'; "+
"field 'AllowedValues' and field 'DeriveAllowedValuesFromDatasource' are mutually exclusive\n", pf.Name))
}
}
}
}
if len(errMessages) > 0 {
return fmt.Errorf("invalid config provided:\n%v", errMessages)
}
return nil
}

// validateLocalPaths validates the existence of local paths.
// Not suitable for general validation while unmarshalling.
// Because that could happen on another machine.
Expand Down
Loading
Loading