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

geojson: handle extra/foreign members in featureCollection #56

Merged
merged 6 commits into from
Jan 16, 2021
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
36 changes: 30 additions & 6 deletions geojson/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ The package also provides helper functions such as `UnmarshalFeatureCollection`
```go
rawJSON := []byte(`
{ "type": "FeatureCollection",
"features": [
{ "type": "Feature",
"geometry": {"type": "Point", "coordinates": [102.0, 0.5]},
"properties": {"prop0": "value0"}
}
]
"features": [
{ "type": "Feature",
"geometry": {"type": "Point", "coordinates": [102.0, 0.5]},
"properties": {"prop0": "value0"}
}
]
}`)

fc, _ := geojson.UnmarshalFeatureCollection(rawJSON)
Expand All @@ -45,6 +45,30 @@ rawJSON, _ := fc.MarshalJSON()
blob, _ := json.Marshal(fc)
```

#### Foreign/extra members in a feature collection

```go
rawJSON := []byte(`
{ "type": "FeatureCollection",
"generator": "myapp",
"timestamp": "2020-06-15T01:02:03Z",
"features": [
{ "type": "Feature",
"geometry": {"type": "Point", "coordinates": [102.0, 0.5]},
"properties": {"prop0": "value0"}
}
]
}`)

fc, _ := geojson.UnmarshalFeatureCollection(rawJSON)

fc.ExtraMembers["generator"] // == "myApp"
fc.ExtraMembers["timestamp"] // == "2020-06-15T01:02:03Z"

// Marshalling will include values in `ExtraMembers` in the
// base featureCollection object.
```

## Feature Properties

GeoJSON features can have properties of any type. This can cause issues in a statically typed
Expand Down
12 changes: 12 additions & 0 deletions geojson/bbox_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
package geojson

import (
"reflect"
"testing"

"github.com/paulmach/orb"
)

func TestBBox(t *testing.T) {
ls := orb.LineString{{1, 3}, {0, 4}}
b := ls.Bound()

bbox := NewBBox(b)
expected := BBox{0, 3, 1, 4}
if !reflect.DeepEqual(bbox, expected) {
t.Errorf("incorrect result: %v != %v", bbox, expected)
}
}

func TestBBoxValid(t *testing.T) {
cases := []struct {
name string
Expand Down
76 changes: 61 additions & 15 deletions geojson/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,21 +28,67 @@ func ExampleFeature_Point() {

func ExampleFeatureCollection_foreignMembers() {
rawJSON := []byte(`
{ "type": "FeatureCollection",
"features": [
{ "type": "Feature",
"geometry": {"type": "Point", "coordinates": [102.0, 0.5]},
"properties": {"prop0": "value0"}
}
],
"title": "Title as Foreign Member"
}`)

type MyFeatureCollection struct {
geojson.FeatureCollection
Title string `json:"title"`
{ "type": "FeatureCollection",
"features": [
{ "type": "Feature",
"geometry": {"type": "Point", "coordinates": [102.0, 0.5]},
"properties": {"prop0": "value0"}
}
],
"title": "Title as Foreign Member"
}`)

fc := geojson.NewFeatureCollection()
json.Unmarshal(rawJSON, &fc)

fmt.Println(fc.Features[0].Geometry)
fmt.Println(fc.ExtraMembers["title"])

data, _ := json.Marshal(fc)
fmt.Println(string(data))

// Output:
// [102 0.5]
// Title as Foreign Member
// {"features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[102,0.5]},"properties":{"prop0":"value0"}}],"title":"Title as Foreign Member","type":"FeatureCollection"}
}

// MyFeatureCollection is a depricated/no longer supported way to extract
// foreign/extra members from a feature collection. Now an UnmarshalJSON
// method, like below, is required for it to work.
type MyFeatureCollection struct {
geojson.FeatureCollection
Title string `json:"title"`
}

// UnmarshalJSON implemented as below is now required for the extra members
// to be decoded directly into the type.
func (fc *MyFeatureCollection) UnmarshalJSON(data []byte) error {
err := json.Unmarshal(data, &fc.FeatureCollection)
if err != nil {
return err
}

fc.Title = fc.ExtraMembers.MustString("title", "")
return nil
}

func ExampleFeatureCollection_foreignMembersCustom() {
// Note: this approach to handling foreign/extra members requires
// implementing an `UnmarshalJSON` method on the new type.
// See MyFeatureCollection type and its UnmarshalJSON function above.

rawJSON := []byte(`
{ "type": "FeatureCollection",
"features": [
{ "type": "Feature",
"geometry": {"type": "Point", "coordinates": [102.0, 0.5]},
"properties": {"prop0": "value0"}
}
],
"title": "Title as Foreign Member"
}`)

fc := &MyFeatureCollection{}
json.Unmarshal(rawJSON, &fc)

Expand Down Expand Up @@ -120,7 +166,6 @@ func ExampleFeatureCollection_MarshalJSON() {

// Output:
// {
// "type": "FeatureCollection",
// "features": [
// {
// "type": "Feature",
Expand All @@ -133,6 +178,7 @@ func ExampleFeatureCollection_MarshalJSON() {
// },
// "properties": null
// }
// ]
// ],
// "type": "FeatureCollection"
// }
}
2 changes: 1 addition & 1 deletion geojson/feature.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ func (f Feature) MarshalJSON() ([]byte, error) {
// Alternately one can call json.Unmarshal(f) directly for the same result.
func UnmarshalFeature(data []byte) (*Feature, error) {
f := &Feature{}
err := json.Unmarshal(data, f)
err := f.UnmarshalJSON(data)
if err != nil {
return nil, err
}
Expand Down
93 changes: 77 additions & 16 deletions geojson/feature_collection.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/*
Package geojson is a library for encoding and decoding GeoJSON into Go structs using
the geometries in the orb package. Supports both the json.Marshaler and json.Unmarshaler
interfaces as well as helper functions such as `UnmarshalFeatureCollection` and `UnmarshalFeature`.
Package geojson is a library for encoding and decoding GeoJSON into Go structs
using the geometries in the orb package. Supports both the json.Marshaler and
json.Unmarshaler interfaces as well as helper functions such as
`UnmarshalFeatureCollection` and `UnmarshalFeature`.
*/
package geojson

Expand All @@ -17,6 +18,11 @@ type FeatureCollection struct {
Type string `json:"type"`
BBox BBox `json:"bbox,omitempty"`
Features []*Feature `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"
// and "features" will not work as those are reserved by the GeoJSON spec.
ExtraMembers Properties `json:"-"`
}

// NewFeatureCollection creates and initializes a new feature collection.
Expand All @@ -36,33 +42,88 @@ func (fc *FeatureCollection) Append(feature *Feature) *FeatureCollection {
// MarshalJSON converts the feature collection object into the proper JSON.
// It will handle the encoding of all the child features and geometries.
// 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) {
type tempFC FeatureCollection
var tmp map[string]interface{}
if fc.ExtraMembers != nil {
tmp = fc.ExtraMembers.Clone()
} else {
tmp = make(map[string]interface{}, 3)
}

c := tempFC{
Type: featureCollection,
BBox: fc.BBox,
Features: fc.Features,
tmp["type"] = featureCollection
delete(tmp, "bbox")
if fc.BBox != nil {
tmp["bbox"] = fc.BBox
}
if fc.Features == nil {
tmp["features"] = []*Feature{}
} else {
tmp["features"] = fc.Features
}

return json.Marshal(tmp)
}

// 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 {
tmp := make(map[string]nocopyRawMessage, 4)

if c.Features == nil {
c.Features = []*Feature{}
err := json.Unmarshal(data, &tmp)
if err != nil {
return err
}
return json.Marshal(c)

*fc = FeatureCollection{}
for key, value := range tmp {
switch key {
case "type":
err := json.Unmarshal(value, &fc.Type)
if err != nil {
return err
}
case "bbox":
err := json.Unmarshal(value, &fc.BBox)
if err != nil {
return err
}
case "features":
err := json.Unmarshal(value, &fc.Features)
if err != nil {
return err
}
default:
if fc.ExtraMembers == nil {
fc.ExtraMembers = Properties{}
}

var val interface{}
err := json.Unmarshal(value, &val)
if err != nil {
return err
}
fc.ExtraMembers[key] = val
}
}

if fc.Type != featureCollection {
return fmt.Errorf("geojson: not a feature collection: type=%s", fc.Type)
}

return nil
}

// UnmarshalFeatureCollection decodes the data into a GeoJSON feature collection.
// Alternately one can call json.Unmarshal(fc) directly for the same result.
func UnmarshalFeatureCollection(data []byte) (*FeatureCollection, error) {
fc := &FeatureCollection{}
err := json.Unmarshal(data, fc)

err := fc.UnmarshalJSON(data)
if err != nil {
return nil, err
}

if fc.Type != featureCollection {
return nil, fmt.Errorf("geojson: not a feature collection: type=%s", fc.Type)
}

return fc, nil
}
80 changes: 80 additions & 0 deletions geojson/feature_collection_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,55 @@ func TestUnmarshalFeatureCollection(t *testing.T) {
}
}

func TestUnmarshalFeatureCollection_errors(t *testing.T) {
t.Run("type not a string", func(t *testing.T) {
rawJSON := `
{ "type": { "foo":"bar" },
"features": [
{ "type": "Feature",
"geometry": {"type": "Point", "coordinates": [102.0, 0.5]},
"properties": {"prop0": "value0"}
}
]
}`

_, err := UnmarshalFeatureCollection([]byte(rawJSON))
if _, ok := err.(*json.UnmarshalTypeError); !ok {
t.Fatalf("wrong error: %T: %v", err, err)
}
})

t.Run("bbox invalid", func(t *testing.T) {
rawJSON := `
{ "type": "FeatureCollection",
"bbox": { "foo":"bar" },
"features": [
{ "type": "Feature",
"geometry": {"type": "Point", "coordinates": [102.0, 0.5]},
"properties": {"prop0": "value0"}
}
]
}`

_, err := UnmarshalFeatureCollection([]byte(rawJSON))
if _, ok := err.(*json.UnmarshalTypeError); !ok {
t.Fatalf("wrong error: %T: %v", err, err)
}
})

t.Run("features invalid", func(t *testing.T) {
rawJSON := `
{ "type": "FeatureCollection",
"features": { "foo":"bar" }
}`

_, err := UnmarshalFeatureCollection([]byte(rawJSON))
if _, ok := err.(*json.UnmarshalTypeError); !ok {
t.Fatalf("wrong error: %T: %v", err, err)
}
})
}

func TestFeatureCollectionMarshalJSON(t *testing.T) {
fc := NewFeatureCollection()
blob, err := fc.MarshalJSON()
Expand Down Expand Up @@ -188,3 +237,34 @@ func TestFeatureCollectionMarshalValue(t *testing.T) {
t.Errorf("json should set features object to at least empty array")
}
}

func TestFeatureCollectionMarshalJSON_extraMembers(t *testing.T) {
rawJSON := `
{ "type": "FeatureCollection",
"foo": "bar",
"features": [
{ "type": "Feature",
"geometry": {"type": "Point", "coordinates": [102.0, 0.5]},
"properties": {"prop0": "value0"}
}
]
}`

fc, err := UnmarshalFeatureCollection([]byte(rawJSON))
if err != nil {
t.Fatalf("should unmarshal feature collection without issue, err %v", err)
}

if v := fc.ExtraMembers.MustString("foo", ""); v != "bar" {
t.Errorf("missing extra: foo: %v", v)
}

data, err := fc.MarshalJSON()
if err != nil {
t.Fatalf("unable to marshal: %v", err)
}

if !bytes.Contains(data, []byte(`"foo":"bar"`)) {
t.Fatalf("extras not in marshalled data")
}
}
Loading