Files
nairobi/app/models/concerns/geojson_format_validator.rb
Javi Martín 1f627d34f1 Make sure polygons contain valid rings
According to the GeoJSON specification [1]:

> * A linear ring is a closed LineString with four or more positions.
> * The first and last positions are equivalent, and they MUST contain
>   identical values; their representation SHOULD also be identical.
> (...)
> * For type "Polygon", the "coordinates" member MUST be an array of
>   linear ring coordinate arrays.

Note that, for simplicity, right now we aren't checking whether the
coordinates are defined counterclockwise for exterior rings and
clockwise for interior rings, which is what the specification expects.

[1] https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.6
2024-12-23 17:35:33 +01:00

128 lines
3.6 KiB
Ruby

class GeojsonFormatValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
if value.present?
geojson = parse_json(value)
unless valid_geojson?(geojson)
record.errors.add(attribute, :invalid)
return
end
unless valid_coordinates?(geojson)
record.errors.add(attribute, :invalid_coordinates)
end
end
end
private
def parse_json(geojson_data)
JSON.parse(geojson_data)
rescue JSON::ParserError
nil
end
def valid_geojson?(geojson)
return false unless geojson.is_a?(Hash)
if geojson["type"] == "FeatureCollection"
valid_feature_collection?(geojson)
elsif geojson["type"] == "Feature"
valid_feature?(geojson)
else
valid_geometry?(geojson)
end
end
def valid_feature_collection?(geojson)
return false unless geojson["features"].is_a?(Array)
geojson["features"].all? { |feature| valid_feature?(feature) }
end
def valid_feature?(feature)
feature["type"] == "Feature" && valid_geometry?(feature["geometry"])
end
def valid_geometry?(geometry)
geometry.is_a?(Hash) && valid_geometry_types.include?(geometry["type"])
end
def valid_geometry_types
[
"Point", "LineString", "Polygon", "MultiPoint", "MultiLineString", "MultiPolygon",
"GeometryCollection"
]
end
def valid_coordinates?(geojson)
if geojson["type"] == "FeatureCollection"
geojson["features"].all? { |feature| valid_coordinates?(feature) }
elsif geojson["type"] == "Feature"
valid_geometry_coordinates?(geojson["geometry"])
else
valid_geometry_coordinates?(geojson)
end
end
def valid_geometry_coordinates?(geometry)
if geometry["type"] == "GeometryCollection"
geometries = geometry["geometries"]
return geometries.is_a?(Array) && geometries.all? { |geom| valid_geometry_coordinates?(geom) }
end
coordinates = geometry["coordinates"]
return false unless coordinates.is_a?(Array)
case geometry["type"]
when "Point"
valid_wgs84_coordinates?(coordinates)
when "LineString"
valid_linestring_coordinates?(coordinates)
when "MultiPoint"
valid_coordinates_array?(coordinates)
when "MultiLineString"
coordinates.all? do |linestring_coordinates|
valid_linestring_coordinates?(linestring_coordinates)
end
when "Polygon"
valid_polygon_coordinates?(coordinates)
when "MultiPolygon"
coordinates.all? do |polygon_coordinates|
valid_polygon_coordinates?(polygon_coordinates)
end
else
false
end
end
def valid_wgs84_coordinates?(coordinates)
return false unless coordinates.is_a?(Array) && coordinates.size == 2
longitude, latitude = coordinates
(-180.0..180.0).include?(longitude) && (-90.0..90.0).include?(latitude)
end
def valid_coordinates_array?(coordinates_array)
coordinates_array.is_a?(Array) &&
coordinates_array.all? { |coordinates| valid_wgs84_coordinates?(coordinates) }
end
def valid_linestring_coordinates?(coordinates)
valid_coordinates_array?(coordinates) && coordinates.many?
end
def valid_polygon_coordinates?(polygon_coordinates)
polygon_coordinates.is_a?(Array) &&
polygon_coordinates.all? { |ring_coordinates| valid_ring_coordinates?(ring_coordinates) }
end
def valid_ring_coordinates?(ring_coordinates)
valid_coordinates_array?(ring_coordinates) &&
ring_coordinates.size >= 4 &&
ring_coordinates.first == ring_coordinates.last
end
end