diff --git a/README.md b/README.md index 4018ebe..6369d60 100644 --- a/README.md +++ b/README.md @@ -89,11 +89,19 @@ Features are defined as: ```go type Feature struct { - ID interface{} `json:"id,omitempty"` + ID any `json:"id,omitempty"` Type string `json:"type"` Geometry orb.Geometry `json:"geometry"` Properties Properties `json:"properties"` } + +// or a generic version with user defined properties type: +type FeatureOf[P] struct { + ID any `json:"id,omitempty"` + Type string `json:"type"` + Geometry orb.Geometry `json:"geometry"` + Properties P `json:"properties"` +} ``` Defining the geometry as an `orb.Geometry` interface along with sub-package functions @@ -107,6 +115,20 @@ for _, f := range fc { } ``` +An example using generic properties: + +```go +type MyProperties struct { + Name string `json:"name"` + Age int `json:"age"` +} + +fc := geojson.FeatureCollectionOf[MyProperties]{} +err := json.Unmarshal(rawJSON, &fc) + +fc.Features[0].Properties.Name // == "Alice" +``` + The library supports third party "encoding/json" replacements such [github.com/json-iterator/go](https://github.com/json-iterator/go). See the [geojson](geojson) readme for more details. diff --git a/geojson/README.md b/geojson/README.md index e328c40..863e58d 100644 --- a/geojson/README.md +++ b/geojson/README.md @@ -3,6 +3,9 @@ This package **encodes and decodes** [GeoJSON](http://geojson.org/) into Go structs using the geometries in the [orb](https://github.com/paulmach/orb) package. +Generics are supported for Feature Properties, but the default is `map[string]any`. +See the [Generic Properties](#generic-properties) section below for more information. + Supports both the [json.Marshaler](https://pkg.go.dev/encoding/json#Marshaler) and [json.Unmarshaler](https://pkg.go.dev/encoding/json#Unmarshaler) interfaces. The package also provides helper functions such as `UnmarshalFeatureCollection` and `UnmarshalFeature`. @@ -131,3 +134,28 @@ f.Properties.MustFloat64(key string, def ...float64) float64 f.Properties.MustInt(key string, def ...int) int f.Properties.MustString(key string, def ...string) string ``` + +## Generic Properties + +Go 1.18 introduced generics, and with it the ability to have a custom type for feature properties. + +```go +type MyProperties struct { + Name string `json:"name"` + Age int `json:"age"` +} + +fc := geojson.FeatureCollectionOf[MyProperties]{} +fc.Append( + &geojson.FeatureOf[MyProperties]{ + Geometry: orb.Point{1, 2}, + Properties: MyProperties{Name: "Alice", Age: 30}, + }, +) + +// unmarshalling can be even easier +fc2 := geojson.FeatureCollectionOf[MyProperties]{} +err := json.Unmarshal(rawJSON, &fc2) + +fc2.Features[0].Properties.Name // == "Alice" +``` diff --git a/geojson/feature.go b/geojson/feature.go index e595193..9b3166b 100644 --- a/geojson/feature.go +++ b/geojson/feature.go @@ -2,19 +2,24 @@ package geojson import ( "bytes" + "encoding/json" "fmt" "github.com/paulmach/orb" "go.mongodb.org/mongo-driver/v2/bson" ) -// A Feature corresponds to GeoJSON feature object -type Feature struct { +// A FeatureOf corresponds to GeoJSON feature object but allows for a generic type for the properties. +// This allows users to unmarshal into a struct instead of a map if they choose. +// +// The code assumes type of P is a struct, or map as the GeoJSON spec requires it +// marshal into the a json object. +type FeatureOf[P any] struct { ID any `json:"id,omitempty"` Type string `json:"type"` BBox BBox `json:"bbox,omitempty"` Geometry orb.Geometry `json:"geometry"` - Properties Properties `json:"properties"` + Properties P `json:"properties"` // ExtraMembers can be used to encoded/decode extra key/members in // the base of the feature object. Note that keys of "id", "type", "bbox" @@ -23,6 +28,9 @@ type Feature struct { ExtraMembers Properties `json:"-"` } +// A Feature corresponds to GeoJSON feature object. +type Feature = FeatureOf[Properties] + // NewFeature creates and initializes a GeoJSON feature given the required attributes. func NewFeature(geometry orb.Geometry) *Feature { return &Feature{ @@ -35,19 +43,23 @@ func NewFeature(geometry orb.Geometry) *Feature { // Point implements the orb.Pointer interface so that Features can be used // with quadtrees. The point returned is the center of the Bound of the geometry. // To represent the geometry with another point you must create a wrapper type. -func (f *Feature) Point() orb.Point { +func (f *FeatureOf[P]) Point() orb.Point { return f.Geometry.Bound().Center() } -var _ orb.Pointer = &Feature{} +var _ orb.Pointer = &FeatureOf[any]{} // MarshalJSON converts the feature object into the proper JSON. // It will handle the encoding of all the child geometries. // Alternately one can call json.Marshal(f) directly for the same result. // Items in the ExtraMembers map will be included in the base of the // feature object. -func (f Feature) MarshalJSON() ([]byte, error) { - return marshalJSON(newFeatureDoc(&f)) +func (f FeatureOf[P]) MarshalJSON() ([]byte, error) { + jProperties, err := f.jsonProperties() + if err != nil { + return nil, err + } + return marshalJSON(f.newFeatureDoc(jProperties)) } // MarshalBSON converts the feature object into the proper JSON. @@ -55,25 +67,51 @@ func (f Feature) MarshalJSON() ([]byte, error) { // Alternately one can call json.Marshal(f) directly for the same result. // Items in the ExtraMembers map will be included in the base of the // feature object. -func (f Feature) MarshalBSON() ([]byte, error) { - return bson.Marshal(newFeatureDoc(&f)) +func (f FeatureOf[P]) MarshalBSON() ([]byte, error) { + properties, err := f.bsonProperties() + if err != nil { + return nil, err + } + return bson.Marshal(f.newFeatureDoc(properties)) +} + +func (f FeatureOf[P]) jsonProperties() (json.RawMessage, error) { + jProperties, err := json.Marshal(f.Properties) + if err != nil { + return nil, err + } + + if len(jProperties) <= 2 { // empty + // we assume it's an object so an empty {} is 2 bytes + // in that case the properties should be nil according to the geojson spec + jProperties = nil + } + + return jProperties, nil } -func newFeatureDoc(f *Feature) any { +func (f FeatureOf[P]) bsonProperties() (any, error) { + t, value, err := bson.MarshalValue(f.Properties) + if err != nil { + return nil, err + } + + if t == bson.TypeEmbeddedDocument && bytes.Equal(value, []byte{5, 0, 0, 0, 0}) { + return nil, nil + } + + return bson.RawValue{Type: t, Value: value}, nil +} + +func (f FeatureOf[P]) newFeatureDoc(properties any) any { if len(f.ExtraMembers) == 0 { - doc := &featureDoc{ + return &featureDoc[any]{ ID: f.ID, Type: "Feature", - Properties: f.Properties, BBox: f.BBox, Geometry: NewGeometry(f.Geometry), + Properties: properties, } - - if len(doc.Properties) == 0 { - doc.Properties = nil - } - - return doc } var tmp map[string]any @@ -95,12 +133,7 @@ func newFeatureDoc(f *Feature) any { } tmp["geometry"] = NewGeometry(f.Geometry) - - if len(f.Properties) == 0 { - tmp["properties"] = nil - } else { - tmp["properties"] = f.Properties - } + tmp["properties"] = properties return tmp } @@ -119,9 +152,9 @@ func UnmarshalFeature(data []byte) (*Feature, error) { // UnmarshalJSON handles the correct unmarshalling of the data // into the orb.Geometry types. -func (f *Feature) UnmarshalJSON(data []byte) error { +func (f *FeatureOf[P]) UnmarshalJSON(data []byte) error { if bytes.Equal(data, []byte(`null`)) { - *f = Feature{} + *f = FeatureOf[P]{} return nil } @@ -132,7 +165,7 @@ func (f *Feature) UnmarshalJSON(data []byte) error { return err } - *f = Feature{} + *f = FeatureOf[P]{} for key, value := range tmp { switch key { case "id": @@ -187,7 +220,7 @@ func (f *Feature) UnmarshalJSON(data []byte) error { } // UnmarshalBSON will unmarshal a BSON document created with bson.Marshal. -func (f *Feature) UnmarshalBSON(data []byte) error { +func (f *FeatureOf[P]) UnmarshalBSON(data []byte) error { tmp := make(map[string]bson.RawValue, 4) err := bson.Unmarshal(data, &tmp) @@ -195,7 +228,7 @@ func (f *Feature) UnmarshalBSON(data []byte) error { return err } - *f = Feature{} + *f = FeatureOf[P]{} for key, value := range tmp { switch key { case "id": @@ -246,10 +279,10 @@ func (f *Feature) UnmarshalBSON(data []byte) error { return nil } -type featureDoc struct { - ID any `json:"id,omitempty" bson:"id"` - Type string `json:"type" bson:"type"` - BBox BBox `json:"bbox,omitempty" bson:"bbox,omitempty"` - Geometry *Geometry `json:"geometry" bson:"geometry"` - Properties Properties `json:"properties" bson:"properties"` +type featureDoc[P any] struct { + ID any `json:"id,omitempty" bson:"id"` + Type string `json:"type" bson:"type"` + BBox BBox `json:"bbox,omitempty" bson:"bbox,omitempty"` + Geometry *Geometry `json:"geometry" bson:"geometry"` + Properties P `json:"properties" bson:"properties"` } diff --git a/geojson/feature_collection.go b/geojson/feature_collection.go index 61113ce..423a70b 100644 --- a/geojson/feature_collection.go +++ b/geojson/feature_collection.go @@ -15,11 +15,15 @@ import ( const featureCollection = "FeatureCollection" -// A FeatureCollection correlates to a GeoJSON feature collection. -type FeatureCollection struct { - Type string `json:"type"` - BBox BBox `json:"bbox,omitempty"` - Features []*Feature `json:"features"` +// A FeatureCollectionOf correlates to a GeoJSON feature collection but allows for a generic type +// for the properties of the features. +// +// The code assumes type of P is a struct, or map as the GeoJSON spec requires it +// marshal into the a json object. +type FeatureCollectionOf[P any] struct { + Type string `json:"type"` + BBox BBox `json:"bbox,omitempty"` + Features []*FeatureOf[P] `json:"features"` // ExtraMembers can be used to encoded/decode extra key/members in // the base of the feature collection. Note that keys of "type", "bbox" @@ -27,6 +31,9 @@ type FeatureCollection struct { ExtraMembers Properties `json:"-"` } +// A FeatureCollection correlates to a GeoJSON feature collection. +type FeatureCollection = FeatureCollectionOf[Properties] + // NewFeatureCollection creates and initializes a new feature collection. func NewFeatureCollection() *FeatureCollection { return &FeatureCollection{ @@ -36,7 +43,7 @@ func NewFeatureCollection() *FeatureCollection { } // Append appends a feature to the collection. -func (fc *FeatureCollection) Append(feature *Feature) *FeatureCollection { +func (fc *FeatureCollectionOf[P]) Append(feature *FeatureOf[P]) *FeatureCollectionOf[P] { fc.Features = append(fc.Features, feature) return fc } @@ -46,7 +53,7 @@ func (fc *FeatureCollection) Append(feature *Feature) *FeatureCollection { // Alternately one can call json.Marshal(fc) directly for the same result. // Items in the ExtraMembers map will be included in the base of the // feature collection object. -func (fc FeatureCollection) MarshalJSON() ([]byte, error) { +func (fc FeatureCollectionOf[P]) MarshalJSON() ([]byte, error) { m := newFeatureCollectionDoc(fc) return marshalJSON(m) } @@ -56,13 +63,13 @@ func (fc FeatureCollection) MarshalJSON() ([]byte, error) { // and geometries. // Items in the ExtraMembers map will be included in the base of the // feature collection object. -func (fc FeatureCollection) MarshalBSON() ([]byte, error) { +func (fc FeatureCollectionOf[P]) MarshalBSON() ([]byte, error) { m := newFeatureCollectionDoc(fc) return bson.Marshal(m) } -func newFeatureCollectionDoc(fc FeatureCollection) map[string]any { - var tmp map[string]any +func newFeatureCollectionDoc[P any](fc FeatureCollectionOf[P]) map[string]any { + var tmp map[string]interface{} if fc.ExtraMembers != nil { tmp = fc.ExtraMembers.Clone() } else { @@ -75,7 +82,7 @@ func newFeatureCollectionDoc(fc FeatureCollection) map[string]any { tmp["bbox"] = fc.BBox } if fc.Features == nil { - tmp["features"] = []*Feature{} + tmp["features"] = []*FeatureOf[P]{} } else { tmp["features"] = fc.Features } @@ -85,9 +92,9 @@ func newFeatureCollectionDoc(fc FeatureCollection) map[string]any { // UnmarshalJSON decodes the data into a GeoJSON feature collection. // Extra/foreign members will be put into the `ExtraMembers` attribute. -func (fc *FeatureCollection) UnmarshalJSON(data []byte) error { +func (fc *FeatureCollectionOf[P]) UnmarshalJSON(data []byte) error { if bytes.Equal(data, []byte(`null`)) { - *fc = FeatureCollection{} + *fc = FeatureCollectionOf[P]{} return nil } @@ -98,7 +105,7 @@ func (fc *FeatureCollection) UnmarshalJSON(data []byte) error { return err } - *fc = FeatureCollection{} + *fc = FeatureCollectionOf[P]{} for key, value := range tmp { switch key { case "type": @@ -139,7 +146,7 @@ func (fc *FeatureCollection) UnmarshalJSON(data []byte) error { // UnmarshalBSON will unmarshal a BSON document created with bson.Marshal. // Extra/foreign members will be put into the `ExtraMembers` attribute. -func (fc *FeatureCollection) UnmarshalBSON(data []byte) error { +func (fc *FeatureCollectionOf[P]) UnmarshalBSON(data []byte) error { tmp := make(map[string]bson.RawValue, 4) err := bson.Unmarshal(data, &tmp) @@ -147,7 +154,7 @@ func (fc *FeatureCollection) UnmarshalBSON(data []byte) error { return err } - *fc = FeatureCollection{} + *fc = FeatureCollectionOf[P]{} for key, value := range tmp { switch key { case "type": diff --git a/geojson/feature_collection_test.go b/geojson/feature_collection_test.go index 232c73d..7aba284 100644 --- a/geojson/feature_collection_test.go +++ b/geojson/feature_collection_test.go @@ -19,6 +19,54 @@ func TestNewFeatureCollection(t *testing.T) { } } +func TestFeatureCollectionOf_json(t *testing.T) { + type S struct { + Int int + String string + } + + fc := FeatureCollectionOf[S]{} + fc.Append(&FeatureOf[S]{ + Geometry: orb.Point{1, 2}, + Properties: S{ + Int: 42, + String: "foo", + }, + }) + + blob, err := json.Marshal(fc) + if err != nil { + t.Fatalf("error marshalling to json: %v", err) + } + + expected := `{"features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[1,2]},"properties":{"Int":42,"String":"foo"}}],"type":"FeatureCollection"}` + if !bytes.Equal(blob, []byte(expected)) { + t.Errorf("json should marshal correctly, got: %s", string(blob)) + } + + var fc2 FeatureCollectionOf[S] + err = json.Unmarshal(blob, &fc2) + if err != nil { + t.Fatalf("error unmarshalling from json: %v", err) + } + + if fc2.Type != "FeatureCollection" { + t.Errorf("should have type of FeatureCollection, got %v", fc2.Type) + } + + if len(fc2.Features) != 1 { + t.Errorf("should have 1 feature but got %d", len(fc2.Features)) + } + + if fc2.Features[0].Properties.Int != 42 { + t.Errorf("incorrect int property: %v != 42", fc2.Features[0].Properties.Int) + } + + if fc2.Features[0].Properties.String != "foo" { + t.Errorf("incorrect string property: %v != foo", fc2.Features[0].Properties.String) + } +} + func TestUnmarshalFeatureCollection(t *testing.T) { rawJSON := ` { "type": "FeatureCollection", diff --git a/geojson/feature_test.go b/geojson/feature_test.go index c2393a3..54b2008 100644 --- a/geojson/feature_test.go +++ b/geojson/feature_test.go @@ -33,6 +33,107 @@ func TestFeatureMarshalJSON(t *testing.T) { } } +func TestFeatureOf_json(t *testing.T) { + type S struct { + Int int + String string + } + f := FeatureOf[S]{ + Geometry: orb.Point{1, 2}, + Properties: S{ + Int: 1, + String: "foo", + }, + } + blob, err := f.MarshalJSON() + if err != nil { + t.Fatalf("error marshalling to json: %v", err) + } + + expected := `{"type":"Feature","geometry":{"type":"Point","coordinates":[1,2]},"properties":{"Int":1,"String":"foo"}}` + if !bytes.Equal(blob, []byte(expected)) { + t.Errorf("json should marshal correctly, got: %s", string(blob)) + } + + // unmarshal back to struct + var f2 FeatureOf[S] + err = json.Unmarshal(blob, &f2) + if err != nil { + t.Fatalf("error unmarshalling from json: %v", err) + } + + if f2.Type != "Feature" { + t.Errorf("incorrect feature: %v != Feature", f2.Type) + } + + if f2.Properties.Int != 1 { + t.Errorf("incorrect int property: %v != 1", f2.Properties.Int) + } + + if f2.Properties.String != "foo" { + t.Errorf("incorrect string property: %v != foo", f2.Properties.String) + } +} + +func TestFeatureT_bson(t *testing.T) { + type S struct { + Int int `bson:"int"` + String string `bson:"string"` + } + + f := FeatureOf[S]{ + Geometry: orb.Point{1, 2}, + Properties: S{ + Int: 1, + String: "foo", + }, + } + + blob, err := f.MarshalBSON() + if err != nil { + t.Fatalf("error marshalling to bson: %v", err) + } + + var raw bson.M + dec := bson.NewDecoder(bson.NewDocumentReader(bytes.NewReader(blob))) + dec.DefaultDocumentM() + err = dec.Decode(&raw) + if err != nil { + t.Fatalf("error unmarshalling raw bson: %v", err) + } + + properties, ok := raw["properties"].(bson.M) + if !ok { + t.Fatalf("properties should be a bson document, got %T", raw["properties"]) + } + + if properties["int"] != int32(1) { + t.Errorf("incorrect int property in bson: %v != 1", properties["int"]) + } + + if properties["string"] != "foo" { + t.Errorf("incorrect string property in bson: %v != foo", properties["string"]) + } + + var f2 FeatureOf[S] + err = bson.Unmarshal(blob, &f2) + if err != nil { + t.Fatalf("error unmarshalling from bson: %v", err) + } + + if f2.Type != "Feature" { + t.Errorf("incorrect feature: %v != Feature", f2.Type) + } + + if f2.Properties.Int != 1 { + t.Errorf("incorrect int property: %v != 1", f2.Properties.Int) + } + + if f2.Properties.String != "foo" { + t.Errorf("incorrect string property: %v != foo", f2.Properties.String) + } +} + func TestFeatureMarshalJSON_BBox(t *testing.T) { f := NewFeature(orb.Bound{Min: orb.Point{1, 1}, Max: orb.Point{2, 2}}) diff --git a/go.sum b/go.sum index 5be0f1c..dfd3f3b 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,9 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/paulmach/protoscan v0.2.1 h1:rM0FpcTjUMvPUNk2BhPJrreDKetq43ChnL+x1sRg8O8=