From 5f99a3bd4de8ef580ff97513410b9d6242efd00e Mon Sep 17 00:00:00 2001 From: Aleksei Gurianov Date: Tue, 1 Oct 2024 20:34:35 +0300 Subject: [PATCH 1/3] feat: encoding decoding arbitrary Feature properties AI session: https://chatgpt.com/share/66fc3275-e248-8004-b619-34c3a8fe04ef BREAKING CHANGE: API adjusted to supoort encoding and decoding arbitrary Feature properties. Signed-off-by: Aleksei Gurianov --- .../feature_encode_decode.accepted | 2 +- .../featurecollection_encode_decode.accepted | 5 - birdie_snapshots/real_life_feature.accepted | 5 + .../real_life_featurecollection.accepted | 5 + src/gleojson.gleam | 132 ++-- test/gleojson_test.gleam | 723 +++++++----------- 6 files changed, 371 insertions(+), 501 deletions(-) delete mode 100644 birdie_snapshots/featurecollection_encode_decode.accepted create mode 100644 birdie_snapshots/real_life_feature.accepted create mode 100644 birdie_snapshots/real_life_featurecollection.accepted diff --git a/birdie_snapshots/feature_encode_decode.accepted b/birdie_snapshots/feature_encode_decode.accepted index cbae84e..b27915e 100644 --- a/birdie_snapshots/feature_encode_decode.accepted +++ b/birdie_snapshots/feature_encode_decode.accepted @@ -2,4 +2,4 @@ version: 1.2.3 title: feature_encode_decode --- -{"id":"feature-id","type":"Feature","geometry":{"type":"Point","coordinates":[1.0,2.0]},"properties":null} \ No newline at end of file +{"id":"feature-id","type":"Feature","geometry":{"type":"Point","coordinates":[1.0,2.0]},"properties":{"name":"Test Point","value":42.0}} \ No newline at end of file diff --git a/birdie_snapshots/featurecollection_encode_decode.accepted b/birdie_snapshots/featurecollection_encode_decode.accepted deleted file mode 100644 index 9028d22..0000000 --- a/birdie_snapshots/featurecollection_encode_decode.accepted +++ /dev/null @@ -1,5 +0,0 @@ ---- -version: 1.2.3 -title: featurecollection_encode_decode ---- -{"type":"FeatureCollection","features":[{"id":"feature-id","type":"Feature","geometry":{"type":"Point","coordinates":[1.0,2.0]},"properties":null}]} \ No newline at end of file diff --git a/birdie_snapshots/real_life_feature.accepted b/birdie_snapshots/real_life_feature.accepted new file mode 100644 index 0000000..1dfae16 --- /dev/null +++ b/birdie_snapshots/real_life_feature.accepted @@ -0,0 +1,5 @@ +--- +version: 1.2.3 +title: real_life_feature +--- +{"id":"yosemite","type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-119.5383,37.8651],[-119.5127,37.8777],[-119.4939,37.8685],[-119.5383,37.8651]]]},"properties":{"name":"Yosemite National Park","area_sq_km":3029.87,"year_established":1890,"is_protected":true}} \ No newline at end of file diff --git a/birdie_snapshots/real_life_featurecollection.accepted b/birdie_snapshots/real_life_featurecollection.accepted new file mode 100644 index 0000000..db0a7b7 --- /dev/null +++ b/birdie_snapshots/real_life_featurecollection.accepted @@ -0,0 +1,5 @@ +--- +version: 1.2.3 +title: real_life_featurecollection +--- +{"type":"FeatureCollection","features":[{"id":"tokyo","type":"Feature","geometry":{"type":"Point","coordinates":[139.6917,35.6895]},"properties":{"name":"Tokyo","population":37435191,"timezone":"Asia/Tokyo","elevation":40.0}},{"id":"colorado-river","type":"Feature","geometry":{"type":"LineString","coordinates":[[-115.1728,36.1147],[-116.2139,36.5674],[-117.1522,36.6567]]},"properties":{"name":"Colorado River","length_km":2330.0,"countries":["USA","Mexico"]}}]} \ No newline at end of file diff --git a/src/gleojson.gleam b/src/gleojson.gleam index 0b72505..dd1637a 100644 --- a/src/gleojson.gleam +++ b/src/gleojson.gleam @@ -14,9 +14,10 @@ //// //// Then you can use the provided functions to encode and decode GeoJSON data. -import gleam/dict import gleam/dynamic import gleam/json + +// import gleam/list import gleam/option import gleam/result @@ -42,24 +43,24 @@ pub type FeatureId { } /// A feature in a GeoJSON object, consisting of a geometry, properties, and an optional id. -pub type Feature { +pub type Feature(properties) { Feature( geometry: option.Option(Geometry), - properties: option.Option(dict.Dict(String, dynamic.Dynamic)), + properties: option.Option(properties), id: option.Option(FeatureId), ) } /// A collection of features in a GeoJSON object. -pub type FeatureCollection { - FeatureCollection(features: List(Feature)) +pub type FeatureCollection(properties) { + FeatureCollection(features: List(Feature(properties))) } /// A GeoJSON object. -pub type GeoJSON { +pub type GeoJSON(properties) { GeoJSONGeometry(Geometry) - GeoJSONFeature(Feature) - GeoJSONFeatureCollection(FeatureCollection) + GeoJSONFeature(Feature(properties)) + GeoJSONFeatureCollection(FeatureCollection(properties)) } // Encoding Functions @@ -67,13 +68,12 @@ pub type GeoJSON { /// Encodes a geometry into a JSON object. fn encode_geometry(geometry: Geometry) -> json.Json { case geometry { - Point(coordinates) -> { + Point(coordinates) -> json.object([ #("type", json.string("Point")), #("coordinates", json.array(coordinates, of: json.float)), ]) - } - MultiPoint(multipoint) -> { + MultiPoint(multipoint) -> json.object([ #("type", json.string("MultiPoint")), #( @@ -81,8 +81,7 @@ fn encode_geometry(geometry: Geometry) -> json.Json { json.array(multipoint, of: json.array(_, of: json.float)), ), ]) - } - LineString(linestring) -> { + LineString(linestring) -> json.object([ #("type", json.string("LineString")), #( @@ -90,8 +89,7 @@ fn encode_geometry(geometry: Geometry) -> json.Json { json.array(linestring, of: json.array(_, of: json.float)), ), ]) - } - MultiLineString(multilinestring) -> { + MultiLineString(multilinestring) -> json.object([ #("type", json.string("MultiLineString")), #( @@ -102,8 +100,7 @@ fn encode_geometry(geometry: Geometry) -> json.Json { ))), ), ]) - } - Polygon(polygon) -> { + Polygon(polygon) -> json.object([ #("type", json.string("Polygon")), #( @@ -114,8 +111,7 @@ fn encode_geometry(geometry: Geometry) -> json.Json { ))), ), ]) - } - MultiPolygon(multipolygon) -> { + MultiPolygon(multipolygon) -> json.object([ #("type", json.string("MultiPolygon")), #( @@ -129,63 +125,77 @@ fn encode_geometry(geometry: Geometry) -> json.Json { ), ), ]) - } - GeometryCollection(collection) -> { + GeometryCollection(collection) -> json.object([ #("type", json.string("GeometryCollection")), #("geometries", json.array(collection, of: encode_geometry)), ]) - } } } /// Encodes a feature into a JSON object. -fn encode_feature(feature: Feature) -> json.Json { - let Feature(geometry_opt, _properties_opt, id_opt) = feature +fn encode_feature( + properties_encoder: fn(properties) -> json.Json, + feature: Feature(properties), +) -> json.Json { + let Feature(geometry_opt, properties_opt, id_opt) = feature let geometry_json = case geometry_opt { option.Some(geometry) -> encode_geometry(geometry) option.None -> json.null() } - // let properties_json = case properties_opt { - // option.Some(props) -> json.object(props) - // option.None -> json.object([]) - // } + let properties_json = case properties_opt { + option.Some(props) -> properties_encoder(props) + option.None -> json.null() + } + let base_obj = [ #("type", json.string("Feature")), #("geometry", geometry_json), - #("properties", json.null()), + #("properties", properties_json), ] - case id_opt { + let full_obj = case id_opt { option.Some(StringId(id)) -> [#("id", json.string(id)), ..base_obj] option.Some(NumberId(id)) -> [#("id", json.float(id)), ..base_obj] option.None -> base_obj } - |> json.object + json.object(full_obj) } /// Encodes a feature collection into a JSON object. -fn encode_featurecollection(collection: FeatureCollection) -> json.Json { +fn encode_featurecollection( + properties_encoder: fn(properties) -> json.Json, + collection: FeatureCollection(properties), +) -> json.Json { let FeatureCollection(features) = collection json.object([ #("type", json.string("FeatureCollection")), - #("features", json.array(features, of: encode_feature)), + #( + "features", + json.array(features, of: fn(feature) { + encode_feature(properties_encoder, feature) + }), + ), ]) } -/// Encodes a GeoJSON object into a dynamic value. +/// Encodes a GeoJSON object into a JSON value. /// /// ## Example /// /// ```gleam /// let point = GeoJSONGeometry(Point([0.0, 0.0])) -/// let encoded = encode_geojson(point) -/// // encoded will be a dynamic representation of the GeoJSON object +/// let encoded = encode_geojson(point, properties_encoder) +/// // encoded will be a JSON representation of the GeoJSON object /// ``` -pub fn encode_geojson(geojson: GeoJSON) -> json.Json { +pub fn encode_geojson( + geojson: GeoJSON(properties), + properties_encoder: fn(properties) -> json.Json, +) -> json.Json { case geojson { GeoJSONGeometry(geometry) -> encode_geometry(geometry) - GeoJSONFeature(feature) -> encode_feature(feature) - GeoJSONFeatureCollection(collection) -> encode_featurecollection(collection) + GeoJSONFeature(feature) -> encode_feature(properties_encoder, feature) + GeoJSONFeatureCollection(collection) -> + encode_featurecollection(properties_encoder, collection) } } @@ -219,6 +229,12 @@ fn positions_list_list_decoder( dynamic.list(of: positions_list_decoder)(dyn_value) } +fn decode_type_field( + dyn_value: dynamic.Dynamic, +) -> Result(String, List(dynamic.DecodeError)) { + dynamic.field(named: "type", of: dynamic.string)(dyn_value) +} + /// Decodes a geometry from a dynamic value. fn geometry_decoder( dyn_value: dynamic.Dynamic, @@ -272,8 +288,9 @@ fn feature_id_decoder( /// Decodes a feature from a dynamic value. fn feature_decoder( + properties_decoder: dynamic.Decoder(properties), dyn_value: dynamic.Dynamic, -) -> Result(Feature, List(dynamic.DecodeError)) { +) -> Result(Feature(properties), List(dynamic.DecodeError)) { use type_str <- result.try(decode_type_field(dyn_value)) case type_str { "Feature" -> { @@ -285,7 +302,7 @@ fn feature_decoder( let properties_result = dynamic.field( named: "properties", - of: dynamic.optional(dynamic.dict(dynamic.string, dynamic.dynamic)), + of: dynamic.optional(properties_decoder), )(dyn_value) |> result.map_error(fn(_errs) { [ @@ -326,14 +343,18 @@ fn feature_decoder( /// Decodes a feature collection from a dynamic value. fn featurecollection_decoder( - dyn_value, -) -> Result(FeatureCollection, List(dynamic.DecodeError)) { + properties_decoder: dynamic.Decoder(properties), + dyn_value: dynamic.Dynamic, +) -> Result(FeatureCollection(properties), List(dynamic.DecodeError)) { use type_str <- result.try(decode_type_field(dyn_value)) case type_str { "FeatureCollection" -> - dynamic.field(named: "features", of: dynamic.list(of: feature_decoder))( - dyn_value, - ) + dynamic.field( + named: "features", + of: dynamic.list(of: fn(dyn_value) { + feature_decoder(properties_decoder, dyn_value) + }), + )(dyn_value) |> result.map(FeatureCollection) _ -> Error([ @@ -353,22 +374,25 @@ fn featurecollection_decoder( /// ```gleam /// let json_string = "{\"type\":\"Point\",\"coordinates\":[0.0,0.0]}" /// let decoded = json.decode(json_string) -/// |> result.then(geojson_decoder) +/// |> result.then(fn dyn_value { geojson_decoder(properties_decoder, dyn_value) }) /// // decoded will be Ok(GeoJSONGeometry(Point([0.0, 0.0]))) if successful /// ``` /// /// Note: This function expects a valid GeoJSON structure. Invalid or incomplete /// GeoJSON data will result in a decode error. -pub fn geojson_decoder(dyn_value) -> Result(GeoJSON, List(dynamic.DecodeError)) { +pub fn geojson_decoder( + properties_decoder: dynamic.Decoder(properties), + dyn_value: dynamic.Dynamic, +) -> Result(GeoJSON(properties), List(dynamic.DecodeError)) { use type_str <- result.try(decode_type_field(dyn_value)) case type_str { - "Feature" -> result.map(feature_decoder(dyn_value), GeoJSONFeature) + "Feature" -> + result.map(feature_decoder(properties_decoder, dyn_value), GeoJSONFeature) "FeatureCollection" -> - result.map(featurecollection_decoder(dyn_value), GeoJSONFeatureCollection) + result.map( + featurecollection_decoder(properties_decoder, dyn_value), + GeoJSONFeatureCollection, + ) _ -> result.map(geometry_decoder(dyn_value), GeoJSONGeometry) } } - -fn decode_type_field(dyn_value) -> Result(String, List(dynamic.DecodeError)) { - dynamic.field(named: "type", of: dynamic.string)(dyn_value) -} diff --git a/test/gleojson_test.gleam b/test/gleojson_test.gleam index 6517d83..cffd9d7 100644 --- a/test/gleojson_test.gleam +++ b/test/gleojson_test.gleam @@ -1,8 +1,8 @@ import birdie -import gleam/dict import gleam/dynamic import gleam/json import gleam/option +import gleam/result import gleeunit import gleeunit/should @@ -12,517 +12,358 @@ pub fn main() { gleeunit.main() } -fn assert_encode_decode(geojson: gleojson.GeoJSON, name: String) { - let encoded = gleojson.encode_geojson(geojson) |> json.to_string - birdie.snap(encoded, name) - json.decode(from: encoded, using: gleojson.geojson_decoder) - |> should.be_ok - |> should.equal(geojson) -} +// Define custom property types for tests -pub fn point_encode_decode_test() { - gleojson.GeoJSONGeometry(gleojson.Point([1.0, 2.0])) - |> assert_encode_decode("point_encode_decode") +// TestProperties for feature_encode_decode_test +pub type TestProperties { + TestProperties(name: String, value: Float) } -pub fn multipoint_encode_decode_test() { - gleojson.GeoJSONGeometry(gleojson.MultiPoint([[1.0, 2.0], [3.0, 4.0]])) - |> assert_encode_decode("multipoint_encode_decode") +// Encoder for TestProperties +fn test_properties_encoder(props: TestProperties) -> json.Json { + let TestProperties(name, value) = props + json.object([#("name", json.string(name)), #("value", json.float(value))]) } -pub fn linestring_encode_decode_test() { - gleojson.GeoJSONGeometry(gleojson.LineString([[1.0, 2.0], [3.0, 4.0]])) - |> assert_encode_decode("linestring_encode_decode") -} +/// Decoder for TestProperties +fn test_properties_decoder( + dyn_value: dynamic.Dynamic, +) -> Result(TestProperties, List(dynamic.DecodeError)) { + use name <- result.try(dynamic.field(named: "name", of: dynamic.string)( + dyn_value, + )) + use value <- result.try(dynamic.field(named: "value", of: dynamic.float)( + dyn_value, + )) -pub fn polygon_encode_decode_test() { - gleojson.GeoJSONGeometry( - gleojson.Polygon([[[1.0, 2.0], [3.0, 4.0], [5.0, 6.0], [1.0, 2.0]]]), - ) - |> assert_encode_decode("polygon_encode_decode") + Ok(TestProperties(name, value)) } -pub fn multipolygon_encode_decode_test() { - gleojson.GeoJSONGeometry( - gleojson.MultiPolygon([ - [[[1.0, 2.0], [3.0, 4.0], [5.0, 6.0], [1.0, 2.0]]], - [[[7.0, 8.0], [9.0, 10.0], [11.0, 12.0], [7.0, 8.0]]], - ]), +// ParkProperties for real_life_feature_test +pub type ParkProperties { + ParkProperties( + name: String, + area_sq_km: Float, + year_established: Int, + is_protected: Bool, ) - |> assert_encode_decode("multipolygon_encode_decode") } -pub fn geometrycollection_encode_decode_test() { - gleojson.GeoJSONGeometry( - gleojson.GeometryCollection([ - gleojson.Point([1.0, 2.0]), - gleojson.LineString([[3.0, 4.0], [5.0, 6.0]]), - ]), - ) - |> assert_encode_decode("geometrycollection_encode_decode") +// Encoder for ParkProperties +fn park_properties_encoder(props: ParkProperties) -> json.Json { + let ParkProperties(name, area_sq_km, year_established, is_protected) = props + json.object([ + #("name", json.string(name)), + #("area_sq_km", json.float(area_sq_km)), + #("year_established", json.int(year_established)), + #("is_protected", json.bool(is_protected)), + ]) } -pub fn feature_encode_decode_test() { - // let properties = - // dict.from_list([ - // #("name", dynamic.from("Test Point")), - // #("value", dynamic.from(42)), - // ]) - - gleojson.GeoJSONFeature(gleojson.Feature( - geometry: option.Some(gleojson.Point([1.0, 2.0])), - properties: option.None, - id: option.Some(gleojson.StringId("feature-id")), +// Decoder for ParkProperties +fn park_properties_decoder( + dyn_value: dynamic.Dynamic, +) -> Result(ParkProperties, List(dynamic.DecodeError)) { + use name <- result.try(dynamic.field(named: "name", of: dynamic.string)( + dyn_value, )) - |> assert_encode_decode("feature_encode_decode") + use area_sq_km <- result.try(dynamic.field( + named: "area_sq_km", + of: dynamic.float, + )(dyn_value)) + use year_established <- result.try(dynamic.field( + named: "year_established", + of: dynamic.int, + )(dyn_value)) + use is_protected <- result.try(dynamic.field( + named: "is_protected", + of: dynamic.bool, + )(dyn_value)) + Ok(ParkProperties(name, area_sq_km, year_established, is_protected)) } -pub fn featurecollection_encode_decode_test() { - // let properties = - // dict.from_list([ - // #("name", dynamic.from("Test Point")), - // #("value", dynamic.from(42)), - // ]) - - gleojson.GeoJSONFeatureCollection( - gleojson.FeatureCollection([ - gleojson.Feature( - geometry: option.Some(gleojson.Point([1.0, 2.0])), - properties: option.None, - id: option.Some(gleojson.StringId("feature-id")), - ), - ]), - ) - |> assert_encode_decode("featurecollection_encode_decode") -} - -pub fn invalid_type_decode_test() { - dynamic.from( - dict.from_list([ - #("type", dynamic.from("InvalidType")), - #("coordinates", dynamic.from([1.0, 2.0])), - ]), +// Properties type for real_life_featurecollection_test +pub type Properties { + CityProperties( + name: String, + population: Int, + timezone: String, + elevation: Float, ) - |> gleojson.geojson_decoder - |> should.be_error + RiverProperties(name: String, length_km: Float, countries: List(String)) } -pub fn invalid_coordinates_decode_test() { - dynamic.from( - dict.from_list([ - #("type", dynamic.from("Point")), - #("coordinates", dynamic.from("invalid coordinates")), - ]), - ) - |> gleojson.geojson_decoder - |> should.be_error +// Encoder for Properties +fn properties_encoder(props: Properties) -> json.Json { + case props { + CityProperties(name, population, timezone, elevation) -> + json.object([ + #("name", json.string(name)), + #("population", json.int(population)), + #("timezone", json.string(timezone)), + #("elevation", json.float(elevation)), + ]) + RiverProperties(name, length_km, countries) -> + json.object([ + #("name", json.string(name)), + #("length_km", json.float(length_km)), + #("countries", json.array(countries, of: json.string)), + ]) + } } -pub fn featurecollection_decode_test() { - let json_string = - " - { - \"type\": \"FeatureCollection\", - \"features\": [{ - \"type\": \"Feature\", - \"geometry\": { - \"type\": \"Point\", - \"coordinates\": [102.0, 0.5] - }, - \"properties\": { - \"prop0\": \"value0\" - } - }, { - \"type\": \"Feature\", - \"geometry\": { - \"type\": \"LineString\", - \"coordinates\": [ - [102.0, 0.0], - [103.0, 1.0], - [104.0, 0.0], - [105.0, 1.0] - ] - }, - \"properties\": { - \"prop0\": \"value0\", - \"prop1\": 0.0 - } - }, { - \"type\": \"Feature\", - \"geometry\": { - \"type\": \"Polygon\", - \"coordinates\": [ - [ - [100.0, 0.0], - [101.0, 0.0], - [101.0, 1.0], - [100.0, 1.0], - [100.0, 0.0] - ] - ] - }, - \"properties\": { - \"prop0\": \"value0\", - \"prop1\": { - \"this\": \"that\" - } +// Decoder for Properties +fn properties_decoder( + dyn_value: dynamic.Dynamic, +) -> Result(Properties, List(dynamic.DecodeError)) { + use name <- result.try(dynamic.field(named: "name", of: dynamic.string)( + dyn_value, + )) + // Try decoding as CityProperties + let population_result = + dynamic.field(named: "population", of: dynamic.int)(dyn_value) + let timezone_result = + dynamic.field(named: "timezone", of: dynamic.string)(dyn_value) + let elevation_result = + dynamic.field(named: "elevation", of: dynamic.float)(dyn_value) + case population_result, timezone_result, elevation_result { + Ok(population), Ok(timezone), Ok(elevation) -> + Ok(CityProperties(name, population, timezone, elevation)) + _, _, _ -> { + // Try decoding as RiverProperties + let length_km_result = + dynamic.field(named: "length_km", of: dynamic.float)(dyn_value) + let countries_result = + dynamic.field(named: "countries", of: dynamic.list(of: dynamic.string))( + dyn_value, + ) + case length_km_result, countries_result { + Ok(length_km), Ok(countries) -> + Ok(RiverProperties(name, length_km, countries)) + _, _ -> + Error([ + dynamic.DecodeError( + expected: "Properties", + found: "Invalid", + path: [], + ), + ]) } - }] + } } - " +} - // Decode the JSON string into a Dynamic value - let decode_result = - json.decode(from: json_string, using: gleojson.geojson_decoder) +// General assertion function for encoding and decoding +fn assert_encode_decode( + geojson: gleojson.GeoJSON(properties), + properties_encoder: fn(properties) -> json.Json, + properties_decoder: dynamic.Decoder(properties), + name: String, +) { + let encoded = + gleojson.encode_geojson(geojson, properties_encoder) + |> json.to_string - // Ensure decoding was successful - let decoded_geojson = - decode_result - |> should.be_ok + birdie.snap(encoded, name) - // Construct the expected GeoJSON data structure - let point_feature = - gleojson.Feature( - geometry: option.Some(gleojson.Point([102.0, 0.5])), - properties: option.Some( - dict.from_list([#("prop0", dynamic.from("value0"))]), - ), - id: option.None, - ) + json.decode(from: encoded, using: gleojson.geojson_decoder( + properties_decoder, + _, + )) + |> should.be_ok + |> should.equal(geojson) +} - let linestring_feature = - gleojson.Feature( - geometry: option.Some( - gleojson.LineString([ - [102.0, 0.0], - [103.0, 1.0], - [104.0, 0.0], - [105.0, 1.0], - ]), - ), - properties: option.Some( - dict.from_list([ - #("prop0", dynamic.from("value0")), - #("prop1", dynamic.from(0.0)), - ]), - ), - id: option.None, - ) +// Test functions for separate geometries - let polygon_feature = - gleojson.Feature( - geometry: option.Some( - gleojson.Polygon([ - [[100.0, 0.0], [101.0, 0.0], [101.0, 1.0], [100.0, 1.0], [100.0, 0.0]], - ]), - ), - properties: option.Some( - dict.from_list([ - #("prop0", dynamic.from("value0")), - #( - "prop1", - dynamic.from( - dynamic.from(dict.from_list([#("this", dynamic.from("that"))])), - ), - ), - ]), - ), - id: option.None, - ) +pub fn point_encode_decode_test() { + let geojson = gleojson.GeoJSONGeometry(gleojson.Point([1.0, 2.0])) - let expected_geojson = - gleojson.GeoJSONFeatureCollection( - gleojson.FeatureCollection([ - point_feature, - linestring_feature, - polygon_feature, - ]), - ) + // Since there are no properties, use the unit type `Nil` + let properties_encoder = fn(_props) { json.null() } + let properties_decoder = fn(_dyn_value) { Ok(Nil) } - // Compare the decoded GeoJSON with the expected structure - decoded_geojson - |> should.equal(expected_geojson) + assert_encode_decode( + geojson, + properties_encoder, + properties_decoder, + "point_encode_decode", + ) } -pub fn point_example_test() { - let json_string = - " - { - \"type\": \"Point\", - \"coordinates\": [100.0, 0.0] - } - " - - let decode_result = - json.decode(from: json_string, using: gleojson.geojson_decoder) - - let decoded_geojson = - decode_result - |> should.be_ok +pub fn multipoint_encode_decode_test() { + let geojson = + gleojson.GeoJSONGeometry(gleojson.MultiPoint([[1.0, 2.0], [3.0, 4.0]])) - let expected_geojson = gleojson.GeoJSONGeometry(gleojson.Point([100.0, 0.0])) + let properties_encoder = fn(_props) { json.null() } + let properties_decoder = fn(_dyn_value) { Ok(Nil) } - decoded_geojson - |> should.equal(expected_geojson) + assert_encode_decode( + geojson, + properties_encoder, + properties_decoder, + "multipoint_encode_decode", + ) } -pub fn linestring_example_test() { - let json_string = - " - { - \"type\": \"LineString\", - \"coordinates\": [ - [100.0, 0.0], - [101.0, 1.0] - ] - } - " - - let decode_result = - json.decode(from: json_string, using: gleojson.geojson_decoder) - - let decoded_geojson = - decode_result - |> should.be_ok +pub fn linestring_encode_decode_test() { + let geojson = + gleojson.GeoJSONGeometry(gleojson.LineString([[1.0, 2.0], [3.0, 4.0]])) - let expected_geojson = - gleojson.GeoJSONGeometry(gleojson.LineString([[100.0, 0.0], [101.0, 1.0]])) + let properties_encoder = fn(_props) { json.null() } + let properties_decoder = fn(_dyn_value) { Ok(Nil) } - decoded_geojson - |> should.equal(expected_geojson) + assert_encode_decode( + geojson, + properties_encoder, + properties_decoder, + "linestring_encode_decode", + ) } -pub fn polygon_no_holes_example_test() { - let json_string = - " - { - \"type\": \"Polygon\", - \"coordinates\": [ - [ - [100.0, 0.0], - [101.0, 0.0], - [101.0, 1.0], - [100.0, 1.0], - [100.0, 0.0] - ] - ] - } - " +pub fn polygon_encode_decode_test() { + let geojson = + gleojson.GeoJSONGeometry( + gleojson.Polygon([[[1.0, 2.0], [3.0, 4.0], [5.0, 6.0], [1.0, 2.0]]]), + ) - let decode_result = - json.decode(from: json_string, using: gleojson.geojson_decoder) + let properties_encoder = fn(_props) { json.null() } + let properties_decoder = fn(_dyn_value) { Ok(Nil) } - let decoded_geojson = - decode_result - |> should.be_ok + assert_encode_decode( + geojson, + properties_encoder, + properties_decoder, + "polygon_encode_decode", + ) +} - let expected_geojson = +pub fn multipolygon_encode_decode_test() { + let geojson = gleojson.GeoJSONGeometry( - gleojson.Polygon([ - [[100.0, 0.0], [101.0, 0.0], [101.0, 1.0], [100.0, 1.0], [100.0, 0.0]], + gleojson.MultiPolygon([ + [[[1.0, 2.0], [3.0, 4.0], [5.0, 6.0], [1.0, 2.0]]], + [[[7.0, 8.0], [9.0, 10.0], [11.0, 12.0], [7.0, 8.0]]], ]), ) - decoded_geojson - |> should.equal(expected_geojson) -} - -pub fn polygon_with_holes_example_test() { - let json_string = - " - { - \"type\": \"Polygon\", - \"coordinates\": [ - [ - [100.0, 0.0], - [101.0, 0.0], - [101.0, 1.0], - [100.0, 1.0], - [100.0, 0.0] - ], - [ - [100.8, 0.8], - [100.8, 0.2], - [100.2, 0.2], - [100.2, 0.8], - [100.8, 0.8] - ] - ] - } - " + let properties_encoder = fn(_props) { json.null() } + let properties_decoder = fn(_dyn_value) { Ok(Nil) } - let decode_result = - json.decode(from: json_string, using: gleojson.geojson_decoder) - - let decoded_geojson = - decode_result - |> should.be_ok + assert_encode_decode( + geojson, + properties_encoder, + properties_decoder, + "multipolygon_encode_decode", + ) +} - let expected_geojson = +pub fn geometrycollection_encode_decode_test() { + let geojson = gleojson.GeoJSONGeometry( - gleojson.Polygon([ - [[100.0, 0.0], [101.0, 0.0], [101.0, 1.0], [100.0, 1.0], [100.0, 0.0]], - [[100.8, 0.8], [100.8, 0.2], [100.2, 0.2], [100.2, 0.8], [100.8, 0.8]], + gleojson.GeometryCollection([ + gleojson.Point([1.0, 2.0]), + gleojson.LineString([[3.0, 4.0], [5.0, 6.0]]), ]), ) - decoded_geojson - |> should.equal(expected_geojson) + let properties_encoder = fn(_props) { json.null() } + let properties_decoder = fn(_dyn_value) { Ok(Nil) } + + assert_encode_decode( + geojson, + properties_encoder, + properties_decoder, + "geometrycollection_encode_decode", + ) } -pub fn multipoint_example_test() { - let json_string = - " - { - \"type\": \"MultiPoint\", - \"coordinates\": [ - [100.0, 0.0], - [101.0, 1.0] - ] - } - " +// Existing test functions... - let decode_result = - json.decode(from: json_string, using: gleojson.geojson_decoder) +pub fn feature_encode_decode_test() { + let properties = TestProperties("Test Point", 42.0) - let decoded_geojson = - decode_result - |> should.be_ok + let feature = + gleojson.Feature( + geometry: option.Some(gleojson.Point([1.0, 2.0])), + properties: option.Some(properties), + id: option.Some(gleojson.StringId("feature-id")), + ) - let expected_geojson = - gleojson.GeoJSONGeometry(gleojson.MultiPoint([[100.0, 0.0], [101.0, 1.0]])) + let geojson = gleojson.GeoJSONFeature(feature) - decoded_geojson - |> should.equal(expected_geojson) + assert_encode_decode( + geojson, + test_properties_encoder, + test_properties_decoder, + "feature_encode_decode", + ) } -pub fn multilinestring_example_test() { - let json_string = - " - { - \"type\": \"MultiLineString\", - \"coordinates\": [ - [ - [100.0, 0.0], - [101.0, 1.0] - ], - [ - [102.0, 2.0], - [103.0, 3.0] - ] - ] - } - " - - let decode_result = - json.decode(from: json_string, using: gleojson.geojson_decoder) - - let decoded_geojson = - decode_result - |> should.be_ok +pub fn real_life_feature_test() { + let properties = ParkProperties("Yosemite National Park", 3029.87, 1890, True) - let expected_geojson = - gleojson.GeoJSONGeometry( - gleojson.MultiLineString([ - [[100.0, 0.0], [101.0, 1.0]], - [[102.0, 2.0], [103.0, 3.0]], - ]), + let feature = + gleojson.Feature( + geometry: option.Some( + gleojson.Polygon([ + [ + [-119.5383, 37.8651], + [-119.5127, 37.8777], + [-119.4939, 37.8685], + [-119.5383, 37.8651], + ], + ]), + ), + properties: option.Some(properties), + id: option.Some(gleojson.StringId("yosemite")), ) - decoded_geojson - |> should.equal(expected_geojson) -} - -pub fn multipolygon_example_test() { - let json_string = - " - { - \"type\": \"MultiPolygon\", - \"coordinates\": [ - [ - [ - [102.0, 2.0], - [103.0, 2.0], - [103.0, 3.0], - [102.0, 3.0], - [102.0, 2.0] - ] - ], - [ - [ - [100.0, 0.0], - [101.0, 0.0], - [101.0, 1.0], - [100.0, 1.0], - [100.0, 0.0] - ], - [ - [100.2, 0.2], - [100.2, 0.8], - [100.8, 0.8], - [100.8, 0.2], - [100.2, 0.2] - ] - ] - ] - } - " + let geojson = gleojson.GeoJSONFeature(feature) - let decode_result = - json.decode(from: json_string, using: gleojson.geojson_decoder) + assert_encode_decode( + geojson, + park_properties_encoder, + park_properties_decoder, + "real_life_feature", + ) +} - let decoded_geojson = - decode_result - |> should.be_ok +pub fn real_life_featurecollection_test() { + let city_properties = CityProperties("Tokyo", 37_435_191, "Asia/Tokyo", 40.0) - let expected_geojson = - gleojson.GeoJSONGeometry( - gleojson.MultiPolygon([ - [[[102.0, 2.0], [103.0, 2.0], [103.0, 3.0], [102.0, 3.0], [102.0, 2.0]]], - [ - [[100.0, 0.0], [101.0, 0.0], [101.0, 1.0], [100.0, 1.0], [100.0, 0.0]], - [[100.2, 0.2], [100.2, 0.8], [100.8, 0.8], [100.8, 0.2], [100.2, 0.2]], - ], - ]), + let city_feature = + gleojson.Feature( + geometry: option.Some(gleojson.Point([139.6917, 35.6895])), + properties: option.Some(city_properties), + id: option.Some(gleojson.StringId("tokyo")), ) - decoded_geojson - |> should.equal(expected_geojson) -} - -pub fn geometrycollection_example_test() { - let json_string = - " - { - \"type\": \"GeometryCollection\", - \"geometries\": [{ - \"type\": \"Point\", - \"coordinates\": [100.0, 0.0] - }, { - \"type\": \"LineString\", - \"coordinates\": [ - [101.0, 0.0], - [102.0, 1.0] - ] - }] - } - " + let river_properties = + RiverProperties("Colorado River", 2330.0, ["USA", "Mexico"]) - let decode_result = - json.decode(from: json_string, using: gleojson.geojson_decoder) + let river_feature = + gleojson.Feature( + geometry: option.Some( + gleojson.LineString([ + [-115.1728, 36.1147], + [-116.2139, 36.5674], + [-117.1522, 36.6567], + ]), + ), + properties: option.Some(river_properties), + id: option.Some(gleojson.StringId("colorado-river")), + ) - let decoded_geojson = - decode_result - |> should.be_ok + let feature_collection = + gleojson.FeatureCollection([city_feature, river_feature]) - let expected_geojson = - gleojson.GeoJSONGeometry( - gleojson.GeometryCollection([ - gleojson.Point([100.0, 0.0]), - gleojson.LineString([[101.0, 0.0], [102.0, 1.0]]), - ]), - ) + let geojson = gleojson.GeoJSONFeatureCollection(feature_collection) - decoded_geojson - |> should.equal(expected_geojson) + assert_encode_decode( + geojson, + properties_encoder, + properties_decoder, + "real_life_featurecollection", + ) } From d8bfaf826c6295d6715aad316fb87ed818354e08 Mon Sep 17 00:00:00 2001 From: Aleksei Gurianov Date: Tue, 1 Oct 2024 23:40:34 +0300 Subject: [PATCH 2/3] refactor: manually refactor code after generation BREAKING CHANGE: API adjusted to follow Gleam conventions and to be more idiomatic. Signed-off-by: Aleksei Gurianov --- NOTICE | 2 +- README.md | 91 +++++---- src/gleojson.gleam | 405 ++++++++++++++++++++++++--------------- test/gleojson_test.gleam | 168 +++++----------- 4 files changed, 354 insertions(+), 312 deletions(-) diff --git a/NOTICE b/NOTICE index 355a1ff..3148777 100644 --- a/NOTICE +++ b/NOTICE @@ -8,7 +8,7 @@ This project is licensed under the MIT License. See the LICENSE file for the ful This project was bootstrapped with assistance from the OpenAI `o1-preview` model. It was used to scaffold basic types and encoder/decoder functions according to [RFC 7946](https://datatracker.ietf.org/doc/html/rfc7946). Manual edits were made to adjust the generated code to the actual Gleam language and to fix compilation errors. -Captured assistance session could be found in initial commit message. +Captured assistance session could be found in commit messages. Other AI models from different providers were used to provide suggestions, generate initial code structures, and assist in problem-solving during the early stages of development. diff --git a/README.md b/README.md index c70396d..e7f3048 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,30 @@ [![Package Version](https://img.shields.io/hexpm/v/gleojson)](https://hex.pm/packages/gleojson) [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/gleojson/) -**gleojson** is a GeoJSON parsing and encoding library for Gleam, following the [RFC 7946](https://tools.ietf.org/html/rfc7946) specification. It provides types and utility functions to encode and decode GeoJSON objects such as Points, LineStrings, Polygons, and more. +**gleojson** is a comprehensive GeoJSON parsing and encoding library for Gleam, following the [RFC 7946](https://tools.ietf.org/html/rfc7946) specification. It provides robust types and utility functions to seamlessly encode and decode GeoJSON objects such as Points, LineStrings, Polygons, and more. **Note:** This package is currently in development and has not reached version 1.0.0 yet. The API is considered unstable and may undergo breaking changes in future releases. Please use with caution in production environments and expect potential updates that might require code changes. +## Features + +- Full support for all GeoJSON object types: Point, MultiPoint, LineString, MultiLineString, Polygon, MultiPolygon, GeometryCollection, Feature, and FeatureCollection +- Flexible encoding and decoding of GeoJSON objects +- Custom property support for Feature and FeatureCollection objects +- Type-safe representation of GeoJSON structures + +## Current Limitations + +While **gleojson** aims to fully implement the GeoJSON specification (RFC 7946), some features are still under development. Key areas for future improvement include: + +1. Coordinate validation +1. Antimeridian and pole handling +1. Bounding box support +1. Right-hand rule enforcement for polygon orientation +1. Position array length limitation +1. GeometryCollection usage recommendations + +Despite these limitations, **gleojson** is fully functional for most common GeoJSON use cases. + ## Installation Add **gleojson** to your Gleam project: @@ -17,56 +37,51 @@ gleam add gleojson ## Usage +Here's a basic example of how to use gleojson: + ```gleam +import gleojson +import gleam/json +import gleam/option +import gleam/io + pub fn main() { - let json_string = "{ - \"type\": \"Feature\", - \"geometry\": { - \"type\": \"Point\", - \"coordinates\": [125.6, 10.1] - }, - \"properties\": { - \"name\": \"Dinagat Islands\" - } - }" - // Decode the JSON string into a GeoJSON object - let result = json.decode(from: json_string, using: gleojson.geojson_decoder) - case result { - Ok(geojson) -> { - // Successfully decoded GeoJSON - } - Error(errors) -> { - todo - // Handle decoding errors - // errors contains information about what went wrong during decoding - } - } - - // Construct GeoJSON from types - let geojson = gleojson.GeoJSONFeatureCollection( - gleojson.FeatureCollection([ - gleojson.Feature( - geometry: option.Some(gleojson.Point([1.0, 2.0])), - properties: option.None, - id: option.Some(gleojson.StringId("feature-id")), - ), - ]), + // Create a Point geometry + let point = gleojson.Point([125.6, 10.1]) + + // Create a Feature with the Point geometry + let feature = gleojson.Feature( + geometry: option.Some(point), + properties: option.None, + id: option.Some(gleojson.StringId("example-point")) ) - // Encode to JSON string - gleojson.encode_geojson(geojson) |> json.to_string + // Encode the Feature to GeoJSON + let geojson = gleojson.GeoJSONFeature(feature) + let encoded = gleojson.encode_geojson(geojson, gleojson.properties_null_encoder) + + // Print the encoded GeoJSON + io.println(json.to_string(encoded)) } ``` -Further documentation can be found at https://hexdocs.pm/gleojson. +For more advanced usage, including custom properties and decoding, see the [documentation](https://hexdocs.pm/gleojson). ## Development -``` +To build and test the project: + +```sh gleam build gleam test ``` +## Contributing + +Contributions to gleojson are welcome! Please feel free to submit a Pull Request. Before contributing, please review our [contribution guidelines](CONTRIBUTING.md). + ## License -This project is licensed under the MIT License. See the [LICENSE](LICENSE) and [NOTICE](NOTICE) files for more details. +This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. + +Please see the [NOTICE](NOTICE) file for information about third party components and the use of AI assistance in this project. diff --git a/src/gleojson.gleam b/src/gleojson.gleam index dd1637a..b09a154 100644 --- a/src/gleojson.gleam +++ b/src/gleojson.gleam @@ -16,16 +16,12 @@ import gleam/dynamic import gleam/json - -// import gleam/list import gleam/option import gleam/result -/// A position in a GeoJSON object. pub type Position = List(Float) -/// A Geometry in a GeoJSON object. pub type Geometry { Point(coordinates: Position) MultiPoint(coordinates: List(Position)) @@ -36,13 +32,11 @@ pub type Geometry { GeometryCollection(geometries: List(Geometry)) } -/// Represents either a String or a Number, used for the Feature id. pub type FeatureId { StringId(String) NumberId(Float) } -/// A feature in a GeoJSON object, consisting of a geometry, properties, and an optional id. pub type Feature(properties) { Feature( geometry: option.Option(Geometry), @@ -51,53 +45,39 @@ pub type Feature(properties) { ) } -/// A collection of features in a GeoJSON object. pub type FeatureCollection(properties) { FeatureCollection(features: List(Feature(properties))) } -/// A GeoJSON object. pub type GeoJSON(properties) { GeoJSONGeometry(Geometry) GeoJSONFeature(Feature(properties)) GeoJSONFeatureCollection(FeatureCollection(properties)) } -// Encoding Functions - -/// Encodes a geometry into a JSON object. fn encode_geometry(geometry: Geometry) -> json.Json { case geometry { Point(coordinates) -> json.object([ #("type", json.string("Point")), - #("coordinates", json.array(coordinates, of: json.float)), + #("coordinates", json.array(coordinates, json.float)), ]) MultiPoint(multipoint) -> json.object([ #("type", json.string("MultiPoint")), - #( - "coordinates", - json.array(multipoint, of: json.array(_, of: json.float)), - ), + #("coordinates", json.array(multipoint, json.array(_, json.float))), ]) LineString(linestring) -> json.object([ #("type", json.string("LineString")), - #( - "coordinates", - json.array(linestring, of: json.array(_, of: json.float)), - ), + #("coordinates", json.array(linestring, json.array(_, json.float))), ]) MultiLineString(multilinestring) -> json.object([ #("type", json.string("MultiLineString")), #( "coordinates", - json.array(multilinestring, of: json.array(_, of: json.array( - _, - of: json.float, - ))), + json.array(multilinestring, json.array(_, json.array(_, json.float))), ), ]) Polygon(polygon) -> @@ -105,10 +85,7 @@ fn encode_geometry(geometry: Geometry) -> json.Json { #("type", json.string("Polygon")), #( "coordinates", - json.array(polygon, of: json.array(_, of: json.array( - _, - of: json.float, - ))), + json.array(polygon, json.array(_, json.array(_, json.float))), ), ]) MultiPolygon(multipolygon) -> @@ -116,24 +93,20 @@ fn encode_geometry(geometry: Geometry) -> json.Json { #("type", json.string("MultiPolygon")), #( "coordinates", - json.array( - multipolygon, - of: json.array(_, of: json.array(_, of: json.array( - _, - of: json.float, - ))), - ), + json.array(multipolygon, json.array(_, json.array(_, json.array( + _, + json.float, + )))), ), ]) GeometryCollection(collection) -> json.object([ #("type", json.string("GeometryCollection")), - #("geometries", json.array(collection, of: encode_geometry)), + #("geometries", json.array(collection, encode_geometry)), ]) } } -/// Encodes a feature into a JSON object. fn encode_feature( properties_encoder: fn(properties) -> json.Json, feature: Feature(properties), @@ -145,7 +118,7 @@ fn encode_feature( } let properties_json = case properties_opt { option.Some(props) -> properties_encoder(props) - option.None -> json.null() + _ -> json.null() } let base_obj = [ @@ -161,7 +134,6 @@ fn encode_feature( json.object(full_obj) } -/// Encodes a feature collection into a JSON object. fn encode_featurecollection( properties_encoder: fn(properties) -> json.Json, collection: FeatureCollection(properties), @@ -171,7 +143,7 @@ fn encode_featurecollection( #("type", json.string("FeatureCollection")), #( "features", - json.array(features, of: fn(feature) { + json.array(features, fn(feature) { encode_feature(properties_encoder, feature) }), ), @@ -180,12 +152,50 @@ fn encode_featurecollection( /// Encodes a GeoJSON object into a JSON value. /// +/// This function takes a GeoJSON object and a properties encoder function, +/// and returns a JSON representation of the GeoJSON object. +/// +/// ## Arguments +/// +/// - `geojson`: The GeoJSON object to encode. +/// - `properties_encoder`: A function that encodes the properties of Features and FeatureCollections. +/// +/// ## Returns +/// +/// A JSON representation of the GeoJSON object. +/// /// ## Example /// /// ```gleam -/// let point = GeoJSONGeometry(Point([0.0, 0.0])) -/// let encoded = encode_geojson(point, properties_encoder) -/// // encoded will be a JSON representation of the GeoJSON object +/// import gleojson +/// import gleam/json +/// import gleam/option +/// import gleam/io +/// +/// pub type CustomProperties { +/// CustomProperties(name: String, value: Float) +/// } +/// +/// pub fn custom_properties_encoder(props: CustomProperties) -> json.Json { +/// json.object([ +/// #("name", json.string(props.name)), +/// #("value", json.float(props.value)), +/// ]) +/// } +/// +/// pub fn main() { +/// let point = gleojson.Point([0.0, 0.0]) +/// let properties = CustomProperties("Example", 42.0) +/// let feature = gleojson.Feature( +/// geometry: option.Some(point), +/// properties: option.Some(properties), +/// id: option.Some(gleojson.StringId("example-point")) +/// ) +/// let geojson = gleojson.GeoJSONFeature(feature) +/// +/// let encoded = gleojson.encode_geojson(geojson, custom_properties_encoder) +/// io.println(json.to_string(encoded)) +/// } /// ``` pub fn encode_geojson( geojson: GeoJSON(properties), @@ -199,72 +209,61 @@ pub fn encode_geojson( } } -// Decoding Functions - -/// Decodes a position from a dynamic value. fn position_decoder( dyn_value: dynamic.Dynamic, ) -> Result(Position, List(dynamic.DecodeError)) { - dynamic.list(of: dynamic.float)(dyn_value) + dynamic.list(dynamic.float)(dyn_value) } -/// Decodes a list of positions from a dynamic value. fn positions_decoder( dyn_value: dynamic.Dynamic, ) -> Result(List(Position), List(dynamic.DecodeError)) { - dynamic.list(of: position_decoder)(dyn_value) + dynamic.list(position_decoder)(dyn_value) } -/// Decodes a list of lists of positions from a dynamic value. fn positions_list_decoder( dyn_value: dynamic.Dynamic, ) -> Result(List(List(Position)), List(dynamic.DecodeError)) { - dynamic.list(of: positions_decoder)(dyn_value) + dynamic.list(positions_decoder)(dyn_value) } -/// Decodes a list of lists of lists of positions from a dynamic value. fn positions_list_list_decoder( dyn_value: dynamic.Dynamic, ) -> Result(List(List(List(Position))), List(dynamic.DecodeError)) { - dynamic.list(of: positions_list_decoder)(dyn_value) + dynamic.list(positions_list_decoder)(dyn_value) } fn decode_type_field( dyn_value: dynamic.Dynamic, ) -> Result(String, List(dynamic.DecodeError)) { - dynamic.field(named: "type", of: dynamic.string)(dyn_value) + dynamic.field("type", dynamic.string)(dyn_value) } -/// Decodes a geometry from a dynamic value. fn geometry_decoder( dyn_value: dynamic.Dynamic, ) -> Result(Geometry, List(dynamic.DecodeError)) { use type_str <- result.try(decode_type_field(dyn_value)) case type_str { "Point" -> - dynamic.field(named: "coordinates", of: position_decoder)(dyn_value) + dynamic.field("coordinates", position_decoder)(dyn_value) |> result.map(Point) "MultiPoint" -> - dynamic.field(named: "coordinates", of: positions_decoder)(dyn_value) + dynamic.field("coordinates", positions_decoder)(dyn_value) |> result.map(MultiPoint) "LineString" -> - dynamic.field(named: "coordinates", of: positions_decoder)(dyn_value) + dynamic.field("coordinates", positions_decoder)(dyn_value) |> result.map(LineString) "MultiLineString" -> - dynamic.field(named: "coordinates", of: positions_list_decoder)(dyn_value) + dynamic.field("coordinates", positions_list_decoder)(dyn_value) |> result.map(MultiLineString) "Polygon" -> - dynamic.field(named: "coordinates", of: positions_list_decoder)(dyn_value) + dynamic.field("coordinates", positions_list_decoder)(dyn_value) |> result.map(Polygon) "MultiPolygon" -> - dynamic.field(named: "coordinates", of: positions_list_list_decoder)( - dyn_value, - ) + dynamic.field("coordinates", positions_list_list_decoder)(dyn_value) |> result.map(MultiPolygon) "GeometryCollection" -> - dynamic.field(named: "geometries", of: dynamic.list(of: geometry_decoder))( - dyn_value, - ) + dynamic.field("geometries", dynamic.list(geometry_decoder))(dyn_value) |> result.map(GeometryCollection) _ -> Error([ @@ -277,7 +276,6 @@ fn geometry_decoder( } } -/// Decodes a FeatureId from a dynamic value. fn feature_id_decoder( dyn_value: dynamic.Dynamic, ) -> Result(FeatureId, List(dynamic.DecodeError)) { @@ -286,113 +284,206 @@ fn feature_id_decoder( |> result.lazy_or(fn() { dynamic.float(dyn_value) |> result.map(NumberId) }) } -/// Decodes a feature from a dynamic value. -fn feature_decoder( - properties_decoder: dynamic.Decoder(properties), - dyn_value: dynamic.Dynamic, -) -> Result(Feature(properties), List(dynamic.DecodeError)) { - use type_str <- result.try(decode_type_field(dyn_value)) - case type_str { - "Feature" -> { - let geometry_result = - dynamic.field(named: "geometry", of: dynamic.optional(geometry_decoder))( - dyn_value, - ) - - let properties_result = - dynamic.field( - named: "properties", - of: dynamic.optional(properties_decoder), +fn feature_decoder(properties_decoder: dynamic.Decoder(properties)) { + fn(dyn_value: dynamic.Dynamic) -> Result( + Feature(properties), + List(dynamic.DecodeError), + ) { + use type_str <- result.try(decode_type_field(dyn_value)) + case type_str { + "Feature" -> { + dynamic.decode3( + Feature, + dynamic.field("geometry", dynamic.optional(geometry_decoder)), + dynamic.field("properties", dynamic.optional(properties_decoder)), + dynamic.optional_field("id", feature_id_decoder), )(dyn_value) - |> result.map_error(fn(_errs) { - [ - dynamic.DecodeError(expected: "Properties", found: "Invalid", path: [ - "properties", - ]), - ] - }) - - let id_result = - dynamic.optional_field(named: "id", of: feature_id_decoder)(dyn_value) - |> result.map_error(fn(_errs) { - [dynamic.DecodeError(expected: "ID", found: "Invalid", path: ["id"])] - }) - - let geometry_result = - geometry_result - |> result.map_error(fn(_errs) { - [ - dynamic.DecodeError(expected: "Geometry", found: "Invalid", path: [ - "geometry", - ]), - ] - }) + } - use geometry_opt <- result.try(geometry_result) - use properties_opt <- result.try(properties_result) - use id_opt <- result.try(id_result) - Ok(Feature(geometry_opt, properties_opt, id_opt)) + _ -> + Error([ + dynamic.DecodeError(expected: "Feature", found: type_str, path: [ + "type", + ]), + ]) } - - _ -> - Error([ - dynamic.DecodeError(expected: "Feature", found: type_str, path: ["type"]), - ]) } } -/// Decodes a feature collection from a dynamic value. -fn featurecollection_decoder( - properties_decoder: dynamic.Decoder(properties), - dyn_value: dynamic.Dynamic, -) -> Result(FeatureCollection(properties), List(dynamic.DecodeError)) { - use type_str <- result.try(decode_type_field(dyn_value)) - case type_str { - "FeatureCollection" -> - dynamic.field( - named: "features", - of: dynamic.list(of: fn(dyn_value) { - feature_decoder(properties_decoder, dyn_value) - }), - )(dyn_value) - |> result.map(FeatureCollection) - _ -> - Error([ - dynamic.DecodeError( - expected: "FeatureCollection", - found: type_str, - path: ["type"], - ), - ]) +fn featurecollection_decoder(properties_decoder: dynamic.Decoder(properties)) { + fn(dyn_value: dynamic.Dynamic) -> Result( + FeatureCollection(properties), + List(dynamic.DecodeError), + ) { + use type_str <- result.try(decode_type_field(dyn_value)) + case type_str { + "FeatureCollection" -> + dynamic.decode1( + FeatureCollection, + dynamic.field( + "features", + dynamic.list(feature_decoder(properties_decoder)), + ), + )(dyn_value) + _ -> + Error([ + dynamic.DecodeError( + expected: "FeatureCollection", + found: type_str, + path: ["type"], + ), + ]) + } } } /// Decodes a GeoJSON object from a dynamic value. /// +/// This function takes a dynamic value (typically parsed from JSON) and a properties decoder, +/// and attempts to decode it into a GeoJSON object. +/// +/// ## Arguments +/// +/// - `properties_decoder`: A function that decodes the properties of Features and FeatureCollections. +/// +/// ## Returns +/// +/// A function that takes a dynamic value and returns a Result containing either +/// the decoded GeoJSON object or a list of decode errors. +/// /// ## Example /// /// ```gleam -/// let json_string = "{\"type\":\"Point\",\"coordinates\":[0.0,0.0]}" -/// let decoded = json.decode(json_string) -/// |> result.then(fn dyn_value { geojson_decoder(properties_decoder, dyn_value) }) -/// // decoded will be Ok(GeoJSONGeometry(Point([0.0, 0.0]))) if successful +/// import gleojson +/// import gleam/json +/// import gleam/result +/// import gleam/dynamic +/// import gleam/io +/// import gleam/string +/// +/// pub type CustomProperties { +/// CustomProperties(name: String, value: Float) +/// } +/// +/// pub fn custom_properties_decoder( +/// dyn: dynamic.Dynamic, +/// ) -> Result(CustomProperties, List(dynamic.DecodeError)) { +/// dynamic.decode2( +/// CustomProperties, +/// dynamic.field("name", dynamic.string), +/// dynamic.field("value", dynamic.float), +/// )(dyn) +/// } +/// +/// pub fn main() { +/// let json_string = "{\"type\":\"Feature\",\"geometry\":{\"type\":\"Point\",\"coordinates\":[0.0,0.0]},\"properties\":{\"name\":\"Example\",\"value\":42.0}}" +/// +/// let decoded = +/// json.decode( +/// from: json_string, +/// using: gleojson.geojson_decoder(custom_properties_decoder) +/// ) +/// +/// case decoded { +/// Ok(geojson) -> { +/// // Work with the decoded GeoJSON object +/// case geojson { +/// gleojson.GeoJSONFeature(feature) -> { +/// io.println("Decoded a feature") +/// } +/// _ -> io.println("Decoded a different type of GeoJSON object") +/// } +/// } +/// Error(errors) -> { +/// // Handle decoding errors +/// io.println("Failed to decode: " <> string.join(errors, ", ")) +/// } +/// } +/// } /// ``` /// /// Note: This function expects a valid GeoJSON structure. Invalid or incomplete /// GeoJSON data will result in a decode error. -pub fn geojson_decoder( - properties_decoder: dynamic.Decoder(properties), - dyn_value: dynamic.Dynamic, -) -> Result(GeoJSON(properties), List(dynamic.DecodeError)) { - use type_str <- result.try(decode_type_field(dyn_value)) - case type_str { - "Feature" -> - result.map(feature_decoder(properties_decoder, dyn_value), GeoJSONFeature) - "FeatureCollection" -> - result.map( - featurecollection_decoder(properties_decoder, dyn_value), - GeoJSONFeatureCollection, - ) - _ -> result.map(geometry_decoder(dyn_value), GeoJSONGeometry) +pub fn geojson_decoder(properties_decoder: dynamic.Decoder(properties)) { + fn(dyn_value: dynamic.Dynamic) -> Result( + GeoJSON(properties), + List(dynamic.DecodeError), + ) { + use type_str <- result.try(decode_type_field(dyn_value)) + case type_str { + "Feature" -> + dynamic.decode1(GeoJSONFeature, feature_decoder(properties_decoder)) + "FeatureCollection" -> + dynamic.decode1( + GeoJSONFeatureCollection, + featurecollection_decoder(properties_decoder), + ) + _ -> dynamic.decode1(GeoJSONGeometry, geometry_decoder) + }(dyn_value) } } + +/// Encodes null properties for Features and FeatureCollections. +/// +/// This is a utility function that can be used as the `properties_encoder` +/// argument for `encode_geojson` when you don't need to encode any properties. +/// +/// ## Returns +/// +/// A JSON null value. +/// +/// ## Example +/// +/// ```gleam +/// import gleojson +/// import gleam/json +/// import gleam/option +/// +/// pub fn main() { +/// let point = gleojson.Point([0.0, 0.0]) +/// let feature = gleojson.Feature( +/// geometry: option.Some(point), +/// properties: option.None, +/// id: option.None +/// ) +/// let geojson = gleojson.GeoJSONFeature(feature) +/// +/// let encoded = gleojson.encode_geojson(geojson, gleojson.properties_null_encoder) +/// // The "properties" field in the resulting JSON will be null +/// } +/// ``` +pub fn properties_null_encoder(_props) { + json.null() +} + +/// Decodes null properties for Features and FeatureCollections. +/// +/// This is a utility function that can be used as the `properties_decoder` +/// argument for `geojson_decoder` when you don't need to decode any properties. +/// +/// ## Returns +/// +/// Always returns `Ok(Nil)`. +/// +/// ## Example +/// +/// ```gleam +/// import gleojson +/// import gleam/json +/// import gleam/result +/// +/// pub fn main() { +/// let json_string = "{\"type\":\"Feature\",\"geometry\":{\"type\":\"Point\",\"coordinates\":[0.0,0.0]},\"properties\":null}" +/// +/// let decoded = +/// json.decode( +/// from: json_string, +/// using: gleojson.geojson_decoder(gleojson.properties_null_decoder) +/// ) +/// +/// // The "properties" field in the decoded Feature will be None +/// } +/// ``` +pub fn properties_null_decoder(_dyn) -> Result(Nil, List(dynamic.DecodeError)) { + Ok(Nil) +} diff --git a/test/gleojson_test.gleam b/test/gleojson_test.gleam index cffd9d7..74a9b54 100644 --- a/test/gleojson_test.gleam +++ b/test/gleojson_test.gleam @@ -2,7 +2,6 @@ import birdie import gleam/dynamic import gleam/json import gleam/option -import gleam/result import gleeunit import gleeunit/should @@ -12,34 +11,25 @@ pub fn main() { gleeunit.main() } -// Define custom property types for tests - -// TestProperties for feature_encode_decode_test pub type TestProperties { TestProperties(name: String, value: Float) } -// Encoder for TestProperties fn test_properties_encoder(props: TestProperties) -> json.Json { let TestProperties(name, value) = props json.object([#("name", json.string(name)), #("value", json.float(value))]) } -/// Decoder for TestProperties fn test_properties_decoder( dyn_value: dynamic.Dynamic, ) -> Result(TestProperties, List(dynamic.DecodeError)) { - use name <- result.try(dynamic.field(named: "name", of: dynamic.string)( - dyn_value, - )) - use value <- result.try(dynamic.field(named: "value", of: dynamic.float)( - dyn_value, - )) - - Ok(TestProperties(name, value)) + dynamic.decode2( + TestProperties, + dynamic.field("name", dynamic.string), + dynamic.field("value", dynamic.float), + )(dyn_value) } -// ParkProperties for real_life_feature_test pub type ParkProperties { ParkProperties( name: String, @@ -49,7 +39,6 @@ pub type ParkProperties { ) } -// Encoder for ParkProperties fn park_properties_encoder(props: ParkProperties) -> json.Json { let ParkProperties(name, area_sq_km, year_established, is_protected) = props json.object([ @@ -60,30 +49,19 @@ fn park_properties_encoder(props: ParkProperties) -> json.Json { ]) } -// Decoder for ParkProperties fn park_properties_decoder( dyn_value: dynamic.Dynamic, ) -> Result(ParkProperties, List(dynamic.DecodeError)) { - use name <- result.try(dynamic.field(named: "name", of: dynamic.string)( - dyn_value, - )) - use area_sq_km <- result.try(dynamic.field( - named: "area_sq_km", - of: dynamic.float, - )(dyn_value)) - use year_established <- result.try(dynamic.field( - named: "year_established", - of: dynamic.int, - )(dyn_value)) - use is_protected <- result.try(dynamic.field( - named: "is_protected", - of: dynamic.bool, - )(dyn_value)) - Ok(ParkProperties(name, area_sq_km, year_established, is_protected)) + dynamic.decode4( + ParkProperties, + dynamic.field("name", dynamic.string), + dynamic.field("area_sq_km", dynamic.float), + dynamic.field("year_established", dynamic.int), + dynamic.field("is_protected", dynamic.bool), + )(dyn_value) } -// Properties type for real_life_featurecollection_test -pub type Properties { +pub type MixedFeaturesProperties { CityProperties( name: String, population: Int, @@ -93,8 +71,9 @@ pub type Properties { RiverProperties(name: String, length_km: Float, countries: List(String)) } -// Encoder for Properties -fn properties_encoder(props: Properties) -> json.Json { +fn mixed_features_properties_encoder( + props: MixedFeaturesProperties, +) -> json.Json { case props { CityProperties(name, population, timezone, elevation) -> json.object([ @@ -107,53 +86,31 @@ fn properties_encoder(props: Properties) -> json.Json { json.object([ #("name", json.string(name)), #("length_km", json.float(length_km)), - #("countries", json.array(countries, of: json.string)), + #("countries", json.array(countries, json.string)), ]) } } -// Decoder for Properties -fn properties_decoder( +fn mixed_features_properties_decoder( dyn_value: dynamic.Dynamic, -) -> Result(Properties, List(dynamic.DecodeError)) { - use name <- result.try(dynamic.field(named: "name", of: dynamic.string)( - dyn_value, - )) - // Try decoding as CityProperties - let population_result = - dynamic.field(named: "population", of: dynamic.int)(dyn_value) - let timezone_result = - dynamic.field(named: "timezone", of: dynamic.string)(dyn_value) - let elevation_result = - dynamic.field(named: "elevation", of: dynamic.float)(dyn_value) - case population_result, timezone_result, elevation_result { - Ok(population), Ok(timezone), Ok(elevation) -> - Ok(CityProperties(name, population, timezone, elevation)) - _, _, _ -> { - // Try decoding as RiverProperties - let length_km_result = - dynamic.field(named: "length_km", of: dynamic.float)(dyn_value) - let countries_result = - dynamic.field(named: "countries", of: dynamic.list(of: dynamic.string))( - dyn_value, - ) - case length_km_result, countries_result { - Ok(length_km), Ok(countries) -> - Ok(RiverProperties(name, length_km, countries)) - _, _ -> - Error([ - dynamic.DecodeError( - expected: "Properties", - found: "Invalid", - path: [], - ), - ]) - } - } - } +) -> Result(MixedFeaturesProperties, List(dynamic.DecodeError)) { + dynamic.any([ + dynamic.decode4( + CityProperties, + dynamic.field("name", dynamic.string), + dynamic.field("population", dynamic.int), + dynamic.field("timezone", dynamic.string), + dynamic.field("elevation", dynamic.float), + ), + dynamic.decode3( + RiverProperties, + dynamic.field("name", dynamic.string), + dynamic.field("length_km", dynamic.float), + dynamic.field("countries", dynamic.list(dynamic.string)), + ), + ])(dyn_value) } -// General assertion function for encoding and decoding fn assert_encode_decode( geojson: gleojson.GeoJSON(properties), properties_encoder: fn(properties) -> json.Json, @@ -166,10 +123,10 @@ fn assert_encode_decode( birdie.snap(encoded, name) - json.decode(from: encoded, using: gleojson.geojson_decoder( - properties_decoder, - _, - )) + json.decode( + from: encoded, + using: gleojson.geojson_decoder(properties_decoder), + ) |> should.be_ok |> should.equal(geojson) } @@ -179,14 +136,10 @@ fn assert_encode_decode( pub fn point_encode_decode_test() { let geojson = gleojson.GeoJSONGeometry(gleojson.Point([1.0, 2.0])) - // Since there are no properties, use the unit type `Nil` - let properties_encoder = fn(_props) { json.null() } - let properties_decoder = fn(_dyn_value) { Ok(Nil) } - assert_encode_decode( geojson, - properties_encoder, - properties_decoder, + gleojson.properties_null_encoder, + gleojson.properties_null_decoder, "point_encode_decode", ) } @@ -195,13 +148,10 @@ pub fn multipoint_encode_decode_test() { let geojson = gleojson.GeoJSONGeometry(gleojson.MultiPoint([[1.0, 2.0], [3.0, 4.0]])) - let properties_encoder = fn(_props) { json.null() } - let properties_decoder = fn(_dyn_value) { Ok(Nil) } - assert_encode_decode( geojson, - properties_encoder, - properties_decoder, + gleojson.properties_null_encoder, + gleojson.properties_null_decoder, "multipoint_encode_decode", ) } @@ -210,13 +160,10 @@ pub fn linestring_encode_decode_test() { let geojson = gleojson.GeoJSONGeometry(gleojson.LineString([[1.0, 2.0], [3.0, 4.0]])) - let properties_encoder = fn(_props) { json.null() } - let properties_decoder = fn(_dyn_value) { Ok(Nil) } - assert_encode_decode( geojson, - properties_encoder, - properties_decoder, + gleojson.properties_null_encoder, + gleojson.properties_null_decoder, "linestring_encode_decode", ) } @@ -227,13 +174,10 @@ pub fn polygon_encode_decode_test() { gleojson.Polygon([[[1.0, 2.0], [3.0, 4.0], [5.0, 6.0], [1.0, 2.0]]]), ) - let properties_encoder = fn(_props) { json.null() } - let properties_decoder = fn(_dyn_value) { Ok(Nil) } - assert_encode_decode( geojson, - properties_encoder, - properties_decoder, + gleojson.properties_null_encoder, + gleojson.properties_null_decoder, "polygon_encode_decode", ) } @@ -247,13 +191,10 @@ pub fn multipolygon_encode_decode_test() { ]), ) - let properties_encoder = fn(_props) { json.null() } - let properties_decoder = fn(_dyn_value) { Ok(Nil) } - assert_encode_decode( geojson, - properties_encoder, - properties_decoder, + gleojson.properties_null_encoder, + gleojson.properties_null_decoder, "multipolygon_encode_decode", ) } @@ -267,19 +208,14 @@ pub fn geometrycollection_encode_decode_test() { ]), ) - let properties_encoder = fn(_props) { json.null() } - let properties_decoder = fn(_dyn_value) { Ok(Nil) } - assert_encode_decode( geojson, - properties_encoder, - properties_decoder, + gleojson.properties_null_encoder, + gleojson.properties_null_decoder, "geometrycollection_encode_decode", ) } -// Existing test functions... - pub fn feature_encode_decode_test() { let properties = TestProperties("Test Point", 42.0) @@ -362,8 +298,8 @@ pub fn real_life_featurecollection_test() { assert_encode_decode( geojson, - properties_encoder, - properties_decoder, + mixed_features_properties_encoder, + mixed_features_properties_decoder, "real_life_featurecollection", ) } From ff6c01a6dd10848d3d7d85a0b1b54ecb96935738 Mon Sep 17 00:00:00 2001 From: Aleksei Gurianov Date: Wed, 2 Oct 2024 00:13:23 +0300 Subject: [PATCH 3/3] chore: bump version to 0.2.0 Signed-off-by: Aleksei Gurianov --- gleam.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gleam.toml b/gleam.toml index 87c0de0..520bcdb 100644 --- a/gleam.toml +++ b/gleam.toml @@ -1,5 +1,5 @@ name = "gleojson" -version = "0.1.0" +version = "0.2.0" description = "A Gleam library for encoding and decoding GeoJSON" licences = ["MIT"]