Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
28 changes: 28 additions & 0 deletions geojson/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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"
```
103 changes: 68 additions & 35 deletions geojson/feature.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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{
Expand All @@ -35,45 +43,75 @@ 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.
// 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) 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
Expand All @@ -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
}
Expand All @@ -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
}

Expand All @@ -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":
Expand Down Expand Up @@ -187,15 +220,15 @@ 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)
if err != nil {
return err
}

*f = Feature{}
*f = FeatureOf[P]{}
for key, value := range tmp {
switch key {
case "id":
Expand Down Expand Up @@ -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"`
}
39 changes: 23 additions & 16 deletions geojson/feature_collection.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,25 @@ 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"
// and "features" will not work as those are reserved by the GeoJSON spec.
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{
Expand All @@ -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
}
Expand All @@ -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)
}
Expand All @@ -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 {
Expand All @@ -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
}
Expand All @@ -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
}

Expand All @@ -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":
Expand Down Expand Up @@ -139,15 +146,15 @@ 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)
if err != nil {
return err
}

*fc = FeatureCollection{}
*fc = FeatureCollectionOf[P]{}
for key, value := range tmp {
switch key {
case "type":
Expand Down
Loading
Loading