Skip to content

Commit

Permalink
Change for a more strict geojson handling.
Browse files Browse the repository at this point in the history
- Do not handle m coordinate.
- Raise for unknown geojson `type`.
- Allow less strict polygons (not simple). Which is ok per spec.
- Add various tests.
  • Loading branch information
BuonOmo committed Nov 9, 2020
1 parent 7316c96 commit fe72a9a
Show file tree
Hide file tree
Showing 8 changed files with 276 additions and 52 deletions.
5 changes: 4 additions & 1 deletion lib/rgeo-geojson.rb
@@ -1,3 +1,6 @@
# frozen_string_literal: true

require "rgeo/geo_json"
# Helper for bundler's `require: true` option.
# See {file:lib/rgeo/geo_json/interface.rb} for documentation entry point.

require_relative "rgeo/geo_json"
18 changes: 13 additions & 5 deletions lib/rgeo/geo_json.rb
@@ -1,8 +1,16 @@
# frozen_string_literal: true

require "rgeo"
require "rgeo/geo_json/version"
require "rgeo/geo_json/entities"
require "rgeo/geo_json/coder"
require "rgeo/geo_json/interface"
require "multi_json"
require "rgeo"

module RGeo
module GeoJSON
class Error < RGeo::Error::RGeoError
end
end
end

require_relative "geo_json/version"
require_relative "geo_json/entities"
require_relative "geo_json/coder"
require_relative "geo_json/interface"
82 changes: 47 additions & 35 deletions lib/rgeo/geo_json/coder.rb
Expand Up @@ -6,8 +6,10 @@ module GeoJSON
# the RGeo::Feature::Factory and the RGeo::GeoJSON::EntityFactory to
# be used) so that you can encode and decode without specifying those
# settings every time.

class Coder
class Error < RGeo::GeoJSON::Error
end

# Create a new coder settings object. The geo factory is passed as
# a required argument.
#
Expand All @@ -23,11 +25,37 @@ class Coder
# RGeo::GeoJSON::Feature or RGeo::GeoJSON::FeatureCollection.
# See RGeo::GeoJSON::EntityFactory for more information.
def initialize(opts = {})
@geo_factory = opts[:geo_factory] || RGeo::Cartesian.preferred_factory
@entity_factory = opts[:entity_factory] || EntityFactory.instance
@geo_factory = opts.fetch(
:geo_factory,
RGeo::Cartesian.preferred_factory(uses_lenient_assertions: true)
)
@entity_factory = opts.fetch(:entity_factory, EntityFactory.instance)
if @geo_factory.property(:has_m_coordinate)
# If a GeoJSON has more than 2 elements, the first one should be
# longitude and the second one latitude. M is not part of GeoJSON
# specifications and only kept here for backward compatibilities.
#
# Quote from https://tools.ietf.org/html/rfc7946#section-3.1.1:
#
# > A position is an array of numbers. There MUST be two or more
# > elements. The first two elements are longitude and latitude, or
# > easting and northing, precisely in that order and using decimal
# > numbers. Altitude or elevation MAY be included as an optional third
# > element.
# >
# > Implementations SHOULD NOT extend positions beyond three elements
# > because the semantics of extra elements are unspecified and
# > ambiguous. Historically, some implementations have used a fourth
# > element to carry a linear referencing measure (sometimes denoted as
# > "M") or a numerical timestamp, but in most situations a parser will
# > not be able to properly interpret these values. The interpretation
# > and meaning of additional elements is beyond the scope of this
# > specification, and additional elements MAY be ignored by parsers.
raise Error, "GeoJSON format cannot handle m coordinate."
end

@num_coordinates = 2
@num_coordinates += 1 if @geo_factory.property(:has_z_coordinate)
@num_coordinates += 1 if @geo_factory.property(:has_m_coordinate)
end

# Encode the given object as GeoJSON. The object may be one of the
Expand All @@ -41,17 +69,16 @@ def initialize(opts = {})
# appropriate JSON library installed.
#
# Returns nil if nil is passed in as the object.

def encode(object)
return nil if object.nil?

if @entity_factory.is_feature_collection?(object)
{
"type" => "FeatureCollection",
"features" => @entity_factory.map_feature_collection(object) { |f| encode_feature(f) },
}
elsif @entity_factory.is_feature?(object)
encode_feature(object)
elsif object.nil?
nil
else
encode_geometry(object)
end
Expand Down Expand Up @@ -111,35 +138,20 @@ def encode_feature(object)
end

def encode_geometry(object)
return nil if object.nil?
if object.factory.property(:has_m_coordinate)
raise Error, "GeoJSON format cannot handle m coordinate."
end

case object
when RGeo::Feature::Point
{
"type" => "Point",
"coordinates" => object.coordinates
}
when RGeo::Feature::LineString
when RGeo::Feature::Point,
RGeo::Feature::LineString,
RGeo::Feature::Polygon,
RGeo::Feature::MultiPoint,
RGeo::Feature::MultiLineString,
RGeo::Feature::MultiPolygon
{
"type" => "LineString",
"coordinates" => object.coordinates
}
when RGeo::Feature::Polygon
{
"type" => "Polygon",
"coordinates" => object.coordinates
}
when RGeo::Feature::MultiPoint
{
"type" => "MultiPoint",
"coordinates" => object.coordinates
}
when RGeo::Feature::MultiLineString
{
"type" => "MultiLineString",
"coordinates" => object.coordinates
}
when RGeo::Feature::MultiPolygon
{
"type" => "MultiPolygon",
"type" => object.geometry_type.type_name,
"coordinates" => object.coordinates
}
when RGeo::Feature::GeometryCollection
Expand Down Expand Up @@ -178,7 +190,7 @@ def decode_geometry(input)
when "MultiPolygon"
decode_multi_polygon_coords(input["coordinates"])
else
nil
raise Error, "'#{input['type']}' type is not part of GeoJSON spec."
end
end

Expand Down
2 changes: 0 additions & 2 deletions lib/rgeo/geo_json/entities.rb
Expand Up @@ -11,7 +11,6 @@ module GeoJSON
# implementation need not subclass or even duck-type this class.
# the entity factory mediates all interaction between the GeoJSON
# engine and features.

class Feature
# Create a feature wrapping the given geometry, with the given ID
# and properties.
Expand Down Expand Up @@ -95,7 +94,6 @@ def keys
# FeatureCollection implementation need not subclass or even
# duck-type this class. The entity factory mediates all interaction
# between the GeoJSON engine and feature collections.

class FeatureCollection
include Enumerable

Expand Down
25 changes: 24 additions & 1 deletion lib/rgeo/geo_json/interface.rb
@@ -1,6 +1,30 @@
# frozen_string_literal: true

module RGeo
# `RGeo::GeoJSON` is a part of `RGeo` designed to decode GeoJSON into
# `RGeo::Feature::Geometry`, or encode `RGeo::Feature::Geometry` objects as
# GeoJSON.
#
# This implementation tries to stick to GeoJSON specifications, and may raise
# when trying to decode and invalid GeoJSON string. It may also raise if one
# tries to encode a feature that cannot be handled per GeoJSON spec.
#
# @example Basic usage
# require 'rgeo/geo_json'
#
# str1 = '{"type":"Point","coordinates":[1,2]}'
# geom = RGeo::GeoJSON.decode(str1)
# geom.as_text # => "POINT (1.0 2.0)"
#
# str2 = '{"type":"Feature","geometry":{"type":"Point","coordinates":[2.5,4.0]},"properties":{"color":"red"}}'
# feature = RGeo::GeoJSON.decode(str2)
# feature['color'] # => 'red'
# feature.geometry.as_text # => "POINT (2.5 4.0)"
#
# hash = RGeo::GeoJSON.encode(feature)
# hash.to_json == str2 # => true
#
# @see https://tools.ietf.org/html/rfc7946
module GeoJSON
class << self
# High-level convenience routine for encoding an object as GeoJSON.
Expand All @@ -13,7 +37,6 @@ class << self
# RGeo::GeoJSON::EntityFactory for more information. By default,
# encode supports objects of type RGeo::GeoJSON::Feature and
# RGeo::GeoJSON::FeatureCollection.

def encode(object, opts = {})
Coder.new(opts).encode(object)
end
Expand Down
31 changes: 23 additions & 8 deletions test/basic_test.rb
@@ -1,11 +1,10 @@
# frozen_string_literal: true

require "minitest/autorun"
require "rgeo/geo_json"
require_relative "test_helper"

class BasicTest < Minitest::Test # :nodoc:
def setup
@geo_factory = RGeo::Cartesian.simple_factory(srid: 4326)
@geo_factory = RGeo::GeoJSON.coder.instance_variable_get(:@geo_factory)
@geo_factory_z = RGeo::Cartesian.simple_factory(srid: 4326, has_z_coordinate: true)
@geo_factory_m = RGeo::Cartesian.simple_factory(srid: 4326, has_m_coordinate: true)
@geo_factory_zm = RGeo::Cartesian.simple_factory(srid: 4326, has_z_coordinate: true, has_m_coordinate: true)
Expand All @@ -22,7 +21,7 @@ def test_nil
end

def test_decode_simple_point
json = %({"type":"Point","coordinates":[1,2]})
json = '{"type":"Point","coordinates":[1,2]}'
point = RGeo::GeoJSON.decode(json)
assert_equal "POINT (1.0 2.0)", point.as_text
end
Expand Down Expand Up @@ -60,8 +59,8 @@ def test_point_m
"type" => "Point",
"coordinates" => [10.0, 20.0, -1.0],
}
assert_equal(json, RGeo::GeoJSON.encode(object))
assert(RGeo::GeoJSON.decode(json, geo_factory: @geo_factory_m).eql?(object))
assert_raises(RGeo::GeoJSON::Coder::Error) { RGeo::GeoJSON.encode(object) }
assert_raises(RGeo::GeoJSON::Coder::Error) { RGeo::GeoJSON.decode(json, geo_factory: @geo_factory_m) }
end

def test_point_zm
Expand All @@ -70,8 +69,8 @@ def test_point_zm
"type" => "Point",
"coordinates" => [10.0, 20.0, -1.0, -2.0],
}
assert_equal(json, RGeo::GeoJSON.encode(object))
assert(RGeo::GeoJSON.decode(json, geo_factory: @geo_factory_zm).eql?(object))
assert_raises(RGeo::GeoJSON::Coder::Error) { RGeo::GeoJSON.encode(object) }
assert_raises(RGeo::GeoJSON::Coder::Error) { RGeo::GeoJSON.decode(json, geo_factory: @geo_factory_zm) }
end

def test_line_string
Expand All @@ -94,6 +93,22 @@ def test_polygon
assert(RGeo::GeoJSON.decode(json, geo_factory: @geo_factory).eql?(object))
end

def test_not_simple_polygon
coordinates = [[0, 0], [2, 2], [2, 0], [0, 2], [0, 0]]
object = @geo_factory.polygon(
@geo_factory.line_string(coordinates.map { |x, y| @geo_factory.point(x, y) })
)
json = {
"type" => "Polygon",
"coordinates" => [coordinates]
}
assert_equal(json, RGeo::GeoJSON.encode(object))
assert(
RGeo::GeoJSON.decode(json).eql?(object),
"It should decodes with the uses_lenient_assertions param"
)
end

def test_polygon_complex
object = @geo_factory.polygon(@geo_factory.linear_ring([@geo_factory.point(0, 0), @geo_factory.point(10, 0), @geo_factory.point(10, 10), @geo_factory.point(0, 10), @geo_factory.point(0, 0)]), [@geo_factory.linear_ring([@geo_factory.point(4, 4), @geo_factory.point(6, 5), @geo_factory.point(4, 6), @geo_factory.point(4, 4)])])
json = {
Expand Down

0 comments on commit fe72a9a

Please sign in to comment.