Support FeatureCollection and MultiPolygon in geozones

We're reworking the format validation to correctly interpret feature
collection, feature, and geometry, according to RFC 7946 [1].

Since Leaflet interprets GeoJSON format, we're rendering the GeoJSON as
a layer instead of as a set of points. For that, we're normalizing the
GeoJSON to make sure it contains either a Feature or a
FeatureCollection. We're also adding the Leaflet images to the assets
path so the markers used for point geometries are rendered correctly.

Note we no longer allow a GeoJSON containing a geometry but not a
defined type. Since there might be invalid GeoJSON in existing Consul
Democracy databases, we're normalizing these existing geometry objects
to be part of a feature object.

We're also wrapping the outline points in a FeatureCollection object
because most of the large GIS systems eg ArcGIS, QGIS export geojson as
a complete FeatureCollection.

[1] https://datatracker.ietf.org/doc/html/rfc7946

Co-authored-by: Javi Martín <javim@elretirao.net>
This commit is contained in:
CoslaJohn
2024-07-19 12:13:43 +01:00
committed by Javi Martín
parent bf4e79d42b
commit 5dbe2cbf24
12 changed files with 635 additions and 112 deletions

View File

@@ -14,3 +14,4 @@
//= link print.css
//= link pdf_fonts.css
//= link_tree ../../../vendor/assets/images
//= link_tree ../../../node_modules/leaflet/dist/images

View File

@@ -220,17 +220,22 @@
}
},
addGeozone: function(geozone, map) {
var polygon = L.polygon(geozone.outline_points, {
color: geozone.color,
fillOpacity: 0.3,
className: "map-polygon"
var geojsonData = JSON.parse(geozone.outline_points);
var geoJsonLayer = L.geoJSON(geojsonData, {
style: {
color: geozone.color,
fillOpacity: 0.3,
className: "map-polygon"
},
onEachFeature: function(feature, layer) {
if (geozone.headings) {
layer.bindPopup(geozone.headings.join("<br>"));
}
}
});
if (geozone.headings !== undefined) {
polygon.bindPopup(geozone.headings.join("<br>"));
}
polygon.addTo(map);
geoJsonLayer.addTo(map);
},
getPopupContent: function(data) {
return "<a href='" + data.link + "'>" + data.title + "</a>";

View File

@@ -3,8 +3,13 @@ class GeojsonFormatValidator < ActiveModel::EachValidator
if value.present?
geojson = parse_json(value)
unless geojson?(geojson)
unless valid_geojson?(geojson)
record.errors.add(attribute, :invalid)
return
end
unless valid_coordinates?(geojson)
record.errors.add(attribute, :invalid_coordinates)
end
end
end
@@ -12,12 +17,91 @@ class GeojsonFormatValidator < ActiveModel::EachValidator
private
def parse_json(geojson_data)
JSON.parse(geojson_data) rescue nil
JSON.parse(geojson_data)
rescue JSON::ParserError
nil
end
def geojson?(geojson)
def valid_geojson?(geojson)
return false unless geojson.is_a?(Hash)
geojson.dig("geometry", "coordinates").is_a?(Array)
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", "MultiPoint"
coordinates.all? { |coordinates| valid_wgs84_coordinates?(coordinates) }
when "Polygon", "MultiLineString"
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_polygon_coordinates?(polygon_coordinates)
polygon_coordinates.all? do |ring|
ring.all? { |coordinates| valid_wgs84_coordinates?(coordinates) }
end
end
end

View File

@@ -21,26 +21,40 @@ class Geozone < ApplicationRecord
end
def outline_points
normalized_coordinates.map { |longlat| [longlat.last, longlat.first] }
normalized_geojson&.to_json
end
private
def normalized_coordinates
def normalized_geojson
if geojson.present?
if geojson.match(/"coordinates"\s*:\s*\[\s*\[\s*\[\s*\[/)
coordinates.reduce([], :concat).reduce([], :concat)
elsif geojson.match(/"coordinates"\s*:\s*\[\s*\[\s*\[/)
coordinates.reduce([], :concat)
parsed_geojson = JSON.parse(geojson)
if parsed_geojson["type"] == "FeatureCollection"
parsed_geojson
elsif parsed_geojson["type"] == "Feature"
wrap_in_feature_collection(parsed_geojson)
elsif parsed_geojson["geometry"]
wrap_in_feature_collection(wrap_in_feature(parsed_geojson["geometry"]))
elsif parsed_geojson["type"] && parsed_geojson["coordinates"]
wrap_in_feature_collection(wrap_in_feature(parsed_geojson))
else
coordinates
raise ArgumentError, "Invalid GeoJSON fragment"
end
else
[]
end
end
def coordinates
JSON.parse(geojson)["geometry"]["coordinates"]
def wrap_in_feature(geometry)
{
type: "Feature",
geometry: geometry
}
end
def wrap_in_feature_collection(feature)
{
type: "FeatureCollection",
features: [feature]
}
end
end

View File

@@ -125,6 +125,7 @@ module Consul
config.assets.paths << Rails.root.join("app", "assets", "fonts")
config.assets.paths << Rails.root.join("vendor", "assets", "fonts")
config.assets.paths << Rails.root.join("node_modules", "jquery-ui", "themes", "base")
config.assets.paths << Rails.root.join("node_modules", "leaflet", "dist")
config.assets.paths << Rails.root.join("node_modules")
config.active_job.queue_adapter = :delayed_job

View File

@@ -551,7 +551,8 @@ en:
geozone:
attributes:
geojson:
invalid: "The GeoJSON provided does not follow the correct format. It must follow the \"Polygon\" or \"MultiPolygon\" type format."
invalid: "The GeoJSON provided does not follow the correct format. It must follow the RFC 7946 standard format"
invalid_coordinates: "The GeoJSON provided contains invalid coordinates; the coordinates must be in the required \"Longitude, Latitude\" format and follow the RFC 7946 standard format"
image:
attributes:
attachment:

View File

@@ -551,7 +551,8 @@ es:
geozone:
attributes:
geojson:
invalid: "Los datos GeoJSON proporcionados no tienen el formato correcto. Deben tener un tipo del formato \"Polygon\" o \"MultiPolygon\"."
invalid: "Los datos GeoJSON proporcionados no tienen el formato correcto. Deben seguir el formato estándar RFC 7946"
invalid_coordinates: "Los datos GeoJSON proporcionados contienen coordenadas inválidas; las coordenadas deben utilizar el formato \"Longitud, Latitud\" y seguir el formato estándar RFC 7946"
image:
attributes:
attachment:

View File

@@ -20,7 +20,15 @@ FactoryBot.define do
trait :with_geojson do
geojson do
'{ "geometry": { "type": "Polygon", "coordinates": [[0.117,51.513],[0.118,51.512],[0.119,51.514]] } }'
<<~JSON
{
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [[[0.117, 51.513], [0.118, 51.512], [0.119, 51.514]]]
}
}
JSON
end
end
end

View File

@@ -0,0 +1,329 @@
require "rails_helper"
describe GeojsonFormatValidator do
before do
dummy_model = Class.new do
include ActiveModel::Model
attr_accessor :geojson
validates :geojson, geojson_format: true
end
stub_const("DummyModel", dummy_model)
end
let(:record) { DummyModel.new }
it "is not valid with an empty hash" do
record.geojson = "{}"
expect(record).not_to be_valid
end
it "is not valid with arbitrary keys" do
record.geojson = '{ "invalid": "yes" }'
expect(record).not_to be_valid
end
it "is not valid without a type" do
record.geojson = '{ "coordinates": [1.23, 4.56] }'
expect(record).not_to be_valid
end
it "is not valid without a type but a geometry" do
record.geojson = '{ "geometry": { "type": "Point", "coordinates": [1.23, 4.56] } }'
expect(record).not_to be_valid
end
context "Point geometry" do
it "is not valid without coordinates" do
record.geojson = '{ "type": "Point" }'
expect(record).not_to be_valid
end
it "is not valid with only one the longitude" do
record.geojson = '{ "type": "Point", "coordinates": 1.23 }'
expect(record).not_to be_valid
end
it "is not valid with non-numerical coordinates" do
record.geojson = '{ "type": "Point", "coordinates": ["1.23", "4.56"] }'
expect(record).not_to be_valid
end
it "is not valid with 3-dimensional coordinates" do
record.geojson = '{ "type": "Point", "coordinates": [1.23, 4.56, 7.89] }'
expect(record).not_to be_valid
end
it "is not valid with multiple coordinates" do
record.geojson = '{ "type": "Point", "coordinates": [[1.23, 4.56], [7.89, 10.11]] }'
expect(record).not_to be_valid
end
it "is not valid with a longitude above 180" do
record.geojson = '{ "type": "Point", "coordinates": [180.01, 4.56] }'
expect(record).not_to be_valid
end
it "is not valid with a longitude below -180" do
record.geojson = '{ "type": "Point", "coordinates": [-180.01, 4.56] }'
expect(record).not_to be_valid
end
it "is not valid with a latitude above 90" do
record.geojson = '{ "type": "Point", "coordinates": [1.23, 90.01] }'
expect(record).not_to be_valid
end
it "is not valid with a latitude below -90" do
record.geojson = '{ "type": "Point", "coordinates": [1.23, -90.01] }'
expect(record).not_to be_valid
end
it "is valid with coordinates in the valid range" do
record.geojson = '{ "type": "Point", "coordinates": [1.23, 4.56] }'
expect(record).to be_valid
end
it "is valid with coordinates at the positive end of the range" do
record.geojson = '{ "type": "Point", "coordinates": [180.0, 90.0] }'
expect(record).to be_valid
end
it "is valid with coordinates at the negative end of the range" do
record.geojson = '{ "type": "Point", "coordinates": [-180.0, -90.0] }'
expect(record).to be_valid
end
end
context "LineString or MultiPoint geometry" do
it "is not valid with a one-dimensional array of coordinates" do
record.geojson = '{ "type": "LineString", "coordinates": [1.23, 4.56] }'
expect(record).not_to be_valid
record.geojson = '{ "type": "MultiPoint", "coordinates": [1.23, 4.56] }'
expect(record).not_to be_valid
end
it "is valid with a two-dimensional array including only one point" do
record.geojson = '{ "type": "LineString", "coordinates": [[1.23, 4.56]] }'
expect(record).to be_valid
record.geojson = '{ "type": "MultiPoint", "coordinates": [[1.23, 4.56]] }'
expect(record).to be_valid
end
it "is not valid when some coordinates are invalid" do
record.geojson = '{ "type": "LineString", "coordinates": [[1.23, 4.56], [180.01, 4.56]] }'
expect(record).not_to be_valid
record.geojson = '{ "type": "MultiPoint", "coordinates": [[1.23, 4.56], [180.01, 4.56]] }'
expect(record).not_to be_valid
end
it "is valid when all the coordinates are valid" do
record.geojson = '{ "type": "LineString", "coordinates": [[1.23, 4.56], [7.89, 4.56]] }'
expect(record).to be_valid
record.geojson = '{ "type": "MultiPoint", "coordinates": [[1.23, 4.56], [7.89, 4.56]] }'
expect(record).to be_valid
end
end
context "GeometryCollection" do
it "is not valid if it doesn't contain geometries" do
record.geojson = '{ "type": "GeometryCollection" }'
expect(record).not_to be_valid
end
it "is not valid if geometries is not an array" do
record.geojson = <<~JSON
{
"type": "GeometryCollection",
"geometries": { "type": "Point", "coordinates": [1.23, 4.56] }
}
JSON
expect(record).not_to be_valid
end
it "is valid if the array of geometries is empty" do
record.geojson = '{ "type": "GeometryCollection", "geometries": [] }'
expect(record).to be_valid
end
it "is valid if all geometries are valid" do
record.geojson = <<~JSON
{
"type": "GeometryCollection",
"geometries": [
{
"type": "Point",
"coordinates": [100.0, 0.0]
},
{
"type": "LineString",
"coordinates": [
[101.0, 0.0],
[102.0, 1.0]
]
}
]
}
JSON
expect(record).to be_valid
end
it "is not valid if some geometries are invalid" do
record.geojson = <<~JSON
{
"type": "GeometryCollection",
"geometries": [
{
"type": "Point",
"coordinates": [100.0, 0.0]
},
{
"type": "LineString",
"coordinates": [101.0, 0.0]
}
]
}
JSON
expect(record).not_to be_valid
end
end
context "Feature" do
it "is valid with a valid geometry" do
record.geojson = <<~JSON
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [1.23, 4.56]
}
}
JSON
expect(record).to be_valid
end
it "is not valid with a valid geometry" do
record.geojson = <<~JSON
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [1.23]
}
}
JSON
expect(record).not_to be_valid
end
end
context "FeatureCollection" do
it "is not valid without features" do
record.geojson = '{ "type": "FeatureCollection" }'
end
it "is not valid if features is not an array" do
record.geojson = <<~JSON
{
"type": "FeatureCollection",
"features": {
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [1.23, 4.56]
}
}
}
JSON
end
it "is valid if the array of features is empty" do
record.geojson = '{ "type": "FeatureCollection", "features": [] }'
expect(record).to be_valid
end
it "is valid if all features are valid" do
record.geojson = <<~JSON
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [1.23, 4.56]
}
},
{
"type": "Feature",
"geometry": {
"type": "LineString",
"coordinates": [[101.0, 0.0], [102.0, 1.0]]
}
}
]
}
JSON
expect(record).to be_valid
end
it "is not valid if some features are invalid" do
record.geojson = <<~JSON
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [1.23, 4.56]
}
},
{
"type": "LineString",
"coordinates": [[101.0, 0.0], [102.0, 1.0]]
}
]
}
JSON
expect(record).not_to be_valid
end
end
end

View File

@@ -18,10 +18,20 @@ describe Geozone do
end
it "is not valid with invalid geojson file format" do
geozone.geojson = '{"geo\":{"type":"Incorrect key","coordinates": [
[40.8792937308316, -3.9259027239257],
[40.8788966596619, -3.9249047078766],
[40.8789131852224, -3.9247799675785]]}}'
geozone.geojson = <<~JSON
{
"type": "Feature",
"geometry": {
"type": "Incorrect",
"coordinates": [
[40.8792937308316, -3.9259027239257],
[40.8788966596619, -3.9249047078766],
[40.8789131852224, -3.9247799675785]
]
}
}
JSON
expect(geozone).not_to be_valid
end
@@ -54,99 +64,149 @@ describe Geozone do
end
describe "#outline_points" do
it "returns empty array when geojson is nil" do
expect(geozone.outline_points).to eq([])
it "returns nil when geojson is nil" do
geozone.geojson = nil
expect(geozone.outline_points).to be nil
end
it "returns coordinates array when geojson is not nil" do
geozone = build(:geozone, geojson: '{
"geometry": {
"type": "Polygon",
"coordinates": [
[40.8792937308316, -3.9259027239257],
[40.8788966596619, -3.9249047078766],
[40.8789131852224, -3.9247799675785]
]
it "returns normalized feature collection when geojson is a valid FeatureCollection" do
geozone.geojson = <<~JSON
{
"type": "FeatureCollection",
"features": [{
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [[
[-3.9259027239257, 40.8792937308316],
[-3.9249047078766, 40.8788966596619],
[-3.9247799675785, 40.8789131852224],
[-3.9259027239257, 40.8792937308316]
]]
}
}]
}
}')
JSON
expect(geozone.outline_points).to eq(
[[-3.9259027239257, 40.8792937308316],
[-3.9249047078766, 40.8788966596619],
[-3.9247799675785, 40.8789131852224]]
)
expected = {
type: "FeatureCollection",
features: [{
type: "Feature",
geometry: {
type: "Polygon",
coordinates: [[
[-3.9259027239257, 40.8792937308316],
[-3.9249047078766, 40.8788966596619],
[-3.9247799675785, 40.8789131852224],
[-3.9259027239257, 40.8792937308316]
]]
}
}]
}
expect(geozone.outline_points).to eq expected.to_json
end
it "handles coordinates with three-dimensional arrays" do
geozone = build(:geozone, geojson: '{
"geometry": {
"type": "Polygon",
"coordinates": [[[40.8792937308316, -3.9259027239257],
[40.8788966596619, -3.9249047078766],
[40.8789131852224, -3.9247799675785]]]
it "returns normalized feature collection when geojson is a valid Feature" do
geozone.geojson = <<~JSON
{
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [[
[-3.9259027239257, 40.8792937308316],
[-3.9249047078766, 40.8788966596619],
[-3.9247799675785, 40.8789131852224],
[-3.9259027239257, 40.8792937308316]
]]
}
}
}')
JSON
expect(geozone.outline_points).to eq(
[[-3.9259027239257, 40.8792937308316],
[-3.9249047078766, 40.8788966596619],
[-3.9247799675785, 40.8789131852224]]
)
expected = {
type: "FeatureCollection",
features: [{
type: "Feature",
geometry: {
type: "Polygon",
coordinates: [[
[-3.9259027239257, 40.8792937308316],
[-3.9249047078766, 40.8788966596619],
[-3.9247799675785, 40.8789131852224],
[-3.9259027239257, 40.8792937308316]
]]
}
}]
}
expect(geozone.outline_points).to eq expected.to_json
end
it "handles coordinates with three-dimensional arrays with spaces between brackets" do
geozone = build(:geozone, geojson: '{
"geometry": {
it "returns normalized feature collection when geojson is a valid Geometry object" do
geozone.geojson = <<~JSON
{
"geometry": {
"type": "Polygon",
"coordinates": [[
[-3.9259027239257, 40.8792937308316],
[-3.9249047078766, 40.8788966596619],
[-3.9247799675785, 40.8789131852224],
[-3.9259027239257, 40.8792937308316]
]]
}
}
JSON
expected = {
type: "FeatureCollection",
features: [{
type: "Feature",
geometry: {
type: "Polygon",
coordinates: [[
[-3.9259027239257, 40.8792937308316],
[-3.9249047078766, 40.8788966596619],
[-3.9247799675785, 40.8789131852224],
[-3.9259027239257, 40.8792937308316]
]]
}
}]
}
expect(geozone.outline_points).to eq expected.to_json
end
it "returns normalized feature collection when geojson is a valid top-level Geometry object" do
geozone.geojson = <<~JSON
{
"type": "Polygon",
"coordinates": [[
[40.8792937308316, -3.9259027239257],
[40.8788966596619, -3.9249047078766],
[40.8789131852224, -3.9247799675785]
[-3.9259027239257, 40.8792937308316],
[-3.9249047078766, 40.8788966596619],
[-3.9247799675785, 40.8789131852224],
[-3.9259027239257, 40.8792937308316]
]]
}
}')
JSON
expect(geozone.outline_points).to eq(
[[-3.9259027239257, 40.8792937308316],
[-3.9249047078766, 40.8788966596619],
[-3.9247799675785, 40.8789131852224]]
)
end
expected = {
type: "FeatureCollection",
features: [{
type: "Feature",
geometry: {
type: "Polygon",
coordinates: [[
[-3.9259027239257, 40.8792937308316],
[-3.9249047078766, 40.8788966596619],
[-3.9247799675785, 40.8789131852224],
[-3.9259027239257, 40.8792937308316]
]]
}
}]
}
it "handles coordinates with four-dimensional arrays" do
geozone = build(:geozone, geojson: '{
"geometry": {
"type": "Polygon",
"coordinates": [[[[40.8792937308316, -3.9259027239257],
[40.8788966596619, -3.9249047078766],
[40.8789131852224, -3.9247799675785]]]]
}
}')
expect(geozone.outline_points).to eq(
[[-3.9259027239257, 40.8792937308316],
[-3.9249047078766, 40.8788966596619],
[-3.9247799675785, 40.8789131852224]]
)
end
it "handles coordinates with four-dimensional arrays with spaces between brackets" do
geozone = build(:geozone, geojson: '{
"geometry": {
"type": "Polygon",
"coordinates": [[[
[40.8792937308316, -3.9259027239257],
[40.8788966596619, -3.9249047078766],
[40.8789131852224, -3.9247799675785]
]]]
}
}')
expect(geozone.outline_points).to eq(
[[-3.9259027239257, 40.8792937308316],
[-3.9249047078766, 40.8788966596619],
[-3.9247799675785, 40.8789131852224]]
)
expect(geozone.outline_points).to eq expected.to_json
end
end
end

View File

@@ -110,8 +110,16 @@ describe "Admin geozones", :admin do
scenario "Show polygons when a heading is associated with a geozone" do
Setting["feature.map"] = true
geojson = <<~JSON
{
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [[[-0.1, 51.5], [-0.2, 51.4], [-0.3, 51.6]]]
}
}
JSON
geojson = '{ "geometry": { "type": "Polygon", "coordinates": [[-0.1,51.5],[-0.2,51.4],[-0.3,51.6]] } }'
geozone = create(:geozone, name: "Polygon me!")
budget = create(:budget)
group = create(:budget_group, budget: budget)
@@ -145,7 +153,16 @@ describe "Admin geozones", :admin do
scenario "Show polygons on geozone admin view" do
Setting["feature.map"] = true
geojson = '{ "geometry": { "type": "Polygon", "coordinates": [[-0.1,51.5],[-0.2,51.4],[-0.3,51.6]] } }'
geojson = <<~JSON
{
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [[[-0.1, 51.5], [-0.2, 51.4], [-0.3, 51.6]]]
}
}
JSON
geozone = create(:geozone, name: "Polygon me!", geojson: geojson)
visit admin_geozones_path

View File

@@ -1658,18 +1658,20 @@ describe "Budget Investments" do
scenario "Shows the polygon associated to the current heading" do
triangle = <<~JSON
{
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [[-0.1,51.5],[-0.2,51.4],[-0.3,51.6]]
"coordinates": [[[-0.1, 51.5], [-0.2, 51.4], [-0.3, 51.6]]]
}
}
JSON
rectangle = <<~JSON
{
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [[-0.1,51.5],[-0.2,51.5],[-0.2,51.6],[-0.1,51.6]]
"coordinates": [[[-0.1, 51.5], [-0.2, 51.5], [-0.2, 51.6], [-0.1, 51.6]]]
}
}
JSON