Skip to content

Commit b6f6139

Browse files
committed
Default decoded GeoJSON to SRID 4326 (WGS 84) per the spec
The GeoJSON spec [indicates](https://tools.ietf.org/html/rfc7946#section-4) that all GeoJSON should be assumed to use the WGS 84 datum by default. We should be permissive and allow overriding that datum (as we did previously), but I think the correct behavior here is to make the datum explicit in our decoded `Geo.Geometry.t()` values. This is a breaking change, but one which I expect to have quite little impact on users. (See the CHANGELOG.md for more.) Resolves #129
1 parent 9ad50c3 commit b6f6139

File tree

6 files changed

+109
-19
lines changed

6 files changed

+109
-19
lines changed

CHANGELOG.md

+50
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,55 @@
11
# Changelog
22

3+
## v4.0.0 — 2024-09
4+
5+
### Potentially breaking change: Default decoded GeoJSON to SRID 4326 (WGS 84)
6+
7+
This aligns our GeoJSON decoding with [the GeoJSON spec](https://tools.ietf.org/html/rfc7946#section-4) by making all decoded GeoJSON infer the WGS 84 datum (SRID 4326) by default. Whereas previously when you called `Geo.JSON.decode/1` or `decode!/1`, we would return geometries with an `:srid` of `nil`, we now return `srid: 4326`. Likewise when encoding GeoJSON, we explicitly output a `crs` field indicating the datum.
8+
9+
This is unlikely to break real-world usage unless your implementation was assuming a different datum by default.
10+
11+
A couple examples of the changes:
12+
13+
**Before**:
14+
15+
```elixir
16+
iex> Geo.JSON.decode!(%{"type" => "Point", "coordinates" => [1.0, 2.0]})
17+
%Geo.Point{
18+
coordinates: {1.0, 2.0},
19+
# Note the default SRID
20+
srid: nil
21+
}
22+
```
23+
24+
**After**
25+
26+
```elixir
27+
iex> Geo.JSON.decode!(%{"type" => "Point", "coordinates" => [1.0, 2.0]})
28+
%Geo.Point{
29+
coordinates: {1.0, 2.0},
30+
# New explicit default
31+
srid: 4326
32+
}
33+
```
34+
35+
If you were then encode this value again, you'd end up with a new `crs` field in the output GeoJSON:
36+
37+
```elixir
38+
iex> %{"type" => "Point", "coordinates" => [1.0, 2.0]}
39+
...> |> Geo.JSON.decode!()
40+
...> |> GeoJSON.encode!()
41+
%{
42+
"type" => "Point",
43+
"coordinates" => [1.0, 2.0],
44+
# Note the new `crs` field which was not present in the input to Geo.JSON.decode!/1
45+
"crs" => %{"properties" => %{"name" => "EPSG:4326"}, "type" => "name"}
46+
}
47+
```
48+
49+
This last behavior is the most potentially troublesome. However, we don't have a good way of distinguishing a case where you explicitly had the `crs` set in the input to the decoding function (in which case you would probably also like to have it present in the re-encoded version) compared to one in which it's been inferred.
50+
51+
Thanks to @gworkman for reporting this issue ([#129](https://github.com/felt/geo/issues/129)).
52+
353
## v3.6.0 — 2023-10-19
454

555
As of v3.6.0, `geo` (like [`geo_postgis`](https://github.com/felt/geo_postgis)) is being maintained by the Felt team. As a company building a geospatial product on Elixir, with a track record of [supporting open source software](https://felt.com/open-source), we're excited for the future of the project.

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ _Note_: If you are looking to do geospatial calculations in memory with Geo's st
3333
```elixir
3434
defp deps do
3535
[
36-
{:geo, "~> 3.6"}
36+
{:geo, "~> 4.0"}
3737
]
3838
end
3939
```

lib/geo/json.ex

+4-1
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,16 @@ defmodule Geo.JSON do
77
so that you can use the resulting GeoJSON structure as a property
88
in larger JSON structures.
99
10+
Note that, per [the GeoJSON spec](https://tools.ietf.org/html/rfc7946#section-4),
11+
all geometries are assumed to use the WGS 84 datum (SRID 4326) by default.
12+
1013
## Examples
1114
1215
# Using Jason as the JSON parser for these examples
1316
1417
iex> json = "{ \\"type\\": \\"Point\\", \\"coordinates\\": [100.0, 0.0] }"
1518
...> json |> Jason.decode!() |> Geo.JSON.decode!()
16-
%Geo.Point{coordinates: {100.0, 0.0}, srid: nil}
19+
%Geo.Point{coordinates: {100.0, 0.0}, srid: 4326}
1720
1821
iex> geom = %Geo.Point{coordinates: {100.0, 0.0}, srid: nil}
1922
...> Jason.encode!(geom)

lib/geo/json/decoder.ex

+11
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,9 @@ defmodule Geo.JSON.Decoder do
9696
true ->
9797
raise DecodeError, value: geo_json
9898
end
99+
# Per #129, the GeoJSON spec says all GeoJSON coordinates default to SRID 4326 (WGS 84)
100+
# https://tools.ietf.org/html/rfc7946#section-4
101+
|> default_srid_4326()
99102
end
100103

101104
@doc """
@@ -279,4 +282,12 @@ defmodule Geo.JSON.Decoder do
279282
defp ensure_numeric(other) do
280283
raise ArgumentError, "expected a numeric coordinate, got: #{inspect(other)}"
281284
end
285+
286+
defp default_srid_4326(%{srid: nil} = geom), do: %{geom | srid: 4326}
287+
288+
defp default_srid_4326(%{geometries: geometries} = geom) when is_list(geometries) do
289+
%{geom | geometries: Enum.map(geometries, &default_srid_4326/1)}
290+
end
291+
292+
defp default_srid_4326(geom), do: geom
282293
end

mix.exs

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ defmodule Geo.Mixfile do
22
use Mix.Project
33

44
@source_url "https://github.com/felt/geo"
5-
@version "3.6.0"
5+
@version "4.0.0"
66

77
def project do
88
[

test/geo/json_test.exs

+42-16
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ defmodule Geo.JSON.Test do
3636
json = "{\"type\":\"Point\",\"coordinates\":[100.0,0.0,70.0]}"
3737
geom = Jason.decode!(json) |> Geo.JSON.decode!()
3838

39-
assert(geom == %Geo.PointZ{coordinates: {100.0, 0.0, 70.0}})
39+
assert geom == %Geo.PointZ{coordinates: {100.0, 0.0, 70.0}, srid: 4326}
4040
end
4141

4242
test "LineString to GeoJson" do
@@ -54,7 +54,7 @@ defmodule Geo.JSON.Test do
5454
assert(geom.coordinates == {100.0, 0.0})
5555

5656
new_exjson = Geo.JSON.encode!(geom)
57-
assert(exjson == new_exjson)
57+
assert_geojson_equal(exjson, new_exjson)
5858
end
5959

6060
test "GeoJson Point without coordinates" do
@@ -64,7 +64,7 @@ defmodule Geo.JSON.Test do
6464
assert(is_nil(geom.coordinates))
6565

6666
new_exjson = Geo.JSON.encode!(geom)
67-
assert(exjson == new_exjson)
67+
assert_geojson_equal(exjson, new_exjson)
6868
end
6969

7070
test "GeoJson with SRID to Point and back" do
@@ -78,7 +78,7 @@ defmodule Geo.JSON.Test do
7878
assert(geom.srid == 4326)
7979

8080
new_exjson = Geo.JSON.encode!(geom)
81-
assert(exjson == new_exjson)
81+
assert_geojson_equal(exjson, new_exjson)
8282
end
8383

8484
test "GeoJson to LineString and back" do
@@ -88,7 +88,7 @@ defmodule Geo.JSON.Test do
8888

8989
assert(geom.coordinates == [{100.0, 0.0}, {101.0, 1.0}])
9090
new_exjson = Geo.JSON.encode!(geom)
91-
assert(exjson == new_exjson)
91+
assert_geojson_equal(exjson, new_exjson)
9292
end
9393

9494
test "GeoJson to LineStringZ and back" do
@@ -100,7 +100,7 @@ defmodule Geo.JSON.Test do
100100

101101
assert(geom.coordinates == [{100.0, 0.0, 50.0}, {101.0, 1.0, 20.0}])
102102
new_exjson = Geo.JSON.encode!(geom)
103-
assert(exjson == new_exjson)
103+
assert_geojson_equal(exjson, new_exjson)
104104
end
105105

106106
test "Drops M coordinate when decoding LineStringZM" do
@@ -145,7 +145,7 @@ defmodule Geo.JSON.Test do
145145
)
146146

147147
new_exjson = Geo.JSON.encode!(geom)
148-
assert(exjson == new_exjson)
148+
assert_geojson_equal(exjson, new_exjson)
149149
end
150150

151151
test "GeoJson to MultiPoint and back" do
@@ -155,7 +155,7 @@ defmodule Geo.JSON.Test do
155155

156156
assert(geom.coordinates == [{100.0, 0.0}, {101.0, 1.0}])
157157
new_exjson = Geo.JSON.encode!(geom)
158-
assert(exjson == new_exjson)
158+
assert_geojson_equal(exjson, new_exjson)
159159
end
160160

161161
test "GeoJson to MultiLineString and back" do
@@ -167,7 +167,7 @@ defmodule Geo.JSON.Test do
167167

168168
assert(geom.coordinates == [[{100.0, 0.0}, {101.0, 1.0}], [{102.0, 2.0}, {103.0, 3.0}]])
169169
new_exjson = Geo.JSON.encode!(geom)
170-
assert(exjson == new_exjson)
170+
assert_geojson_equal(exjson, new_exjson)
171171
end
172172

173173
test "GeoJson to MultiLineStringZ and back" do
@@ -185,7 +185,7 @@ defmodule Geo.JSON.Test do
185185
)
186186

187187
new_exjson = Geo.JSON.encode!(geom)
188-
assert(exjson == new_exjson)
188+
assert_geojson_equal(exjson, new_exjson)
189189
end
190190

191191
test "GeoJson to MultiPolygon and back" do
@@ -206,7 +206,7 @@ defmodule Geo.JSON.Test do
206206
)
207207

208208
new_exjson = Geo.JSON.encode!(geom)
209-
assert(exjson == new_exjson)
209+
assert_geojson_equal(exjson, new_exjson)
210210
end
211211

212212
test "GeoJson to GeometryCollection and back" do
@@ -219,7 +219,7 @@ defmodule Geo.JSON.Test do
219219
assert(Enum.count(geom.geometries) == 2)
220220

221221
new_exjson = Geo.JSON.encode!(geom)
222-
assert(exjson == new_exjson)
222+
assert_geojson_equal(exjson, new_exjson)
223223
end
224224

225225
test "Unable to encode non-geo type" do
@@ -281,7 +281,7 @@ defmodule Geo.JSON.Test do
281281
assert(Enum.count(geom.geometries) == 2)
282282

283283
new_exjson = Geo.JSON.encode!(geom)
284-
assert(exjson == new_exjson)
284+
assert_geojson_equal(exjson, new_exjson)
285285
end
286286

287287
test "GeoJSON to GeometryCollection" do
@@ -454,7 +454,16 @@ defmodule Geo.JSON.Test do
454454
y <- float()
455455
) do
456456
geom = %Geo.Point{coordinates: {x, y}}
457-
assert geom == Geo.JSON.encode!(geom) |> Geo.JSON.decode!()
457+
assert %{geom | srid: 4326} == Geo.JSON.encode!(geom) |> Geo.JSON.decode!()
458+
459+
geom_with_srid_and_props = %Geo.Point{
460+
coordinates: {x, y},
461+
srid: 1234,
462+
properties: %{"foo" => "bar"}
463+
}
464+
465+
assert %{geom_with_srid_and_props | srid: 1234} ==
466+
Geo.JSON.encode!(geom_with_srid_and_props) |> Geo.JSON.decode!()
458467
end
459468
end
460469

@@ -463,13 +472,13 @@ defmodule Geo.JSON.Test do
463472
json = Geo.JSON.encode!(geom) |> Jason.encode!()
464473

465474
assert(json == "{\"coordinates\":[],\"type\":\"Point\"}")
466-
assert geom == Geo.JSON.encode!(geom) |> Geo.JSON.decode!()
475+
assert %{geom | srid: 4326} == Geo.JSON.encode!(geom) |> Geo.JSON.decode!()
467476
end
468477

469478
property "encodes and decodes back to the correct LineString struct" do
470479
check all(list <- list_of({float(), float()}, min_length: 1)) do
471480
geom = %Geo.LineString{coordinates: list}
472-
assert geom == Geo.JSON.encode!(geom) |> Geo.JSON.decode!()
481+
assert %{geom | srid: 4326} == Geo.JSON.encode!(geom) |> Geo.JSON.decode!()
473482
end
474483
end
475484

@@ -588,4 +597,21 @@ defmodule Geo.JSON.Test do
588597
assert Enum.all?(geom.geometries, &match?(%Geo.MultiPolygon{}, &1))
589598
assert geom.properties["id"] == "FLC017"
590599
end
600+
601+
defp assert_geojson_equal(%{} = json_1, %{} = json_2) do
602+
# Per the GeoJSON spec, GeoJSON is assumed to have WGS 84 datum (SRID 4326) by default
603+
assert drop_srid_4326(json_1) == drop_srid_4326(json_2),
604+
"Inequivalent GeoJSON values:\n" <>
605+
"Left:\n" <>
606+
"#{inspect(json_1, pretty: true)}\n" <>
607+
"Right:\n" <>
608+
"#{inspect(json_2, pretty: true)}"
609+
end
610+
611+
defp drop_srid_4326(%{"crs" => crs} = json)
612+
when crs == %{"properties" => %{"name" => "EPSG:4326"}, "type" => "name"} do
613+
Map.delete(json, "crs")
614+
end
615+
616+
defp drop_srid_4326(%{} = json), do: json
591617
end

0 commit comments

Comments
 (0)