NetTopologySuite / NetTopologySuite.IO.GeoJSON

GeoJSON IO module for NTS.
BSD 3-Clause "New" or "Revised" License
111 stars 46 forks source link

Look into adding appropriate support for OpenAPI #68

Open airbreather opened 4 years ago

airbreather commented 4 years ago

As a developer of a web API that uses GeoJSON-formatted inputs and outputs, I want a library maintained by the NetTopologySuite organization that I can use to document the GeoJSON parts of my API (e.g., via Swashbuckle.AspNetCore.SwaggerGen) so that I don't have to understand and recreate the details of the GeoJSON format in my OpenAPI schemas.

This can and should be independent of Json.NET vs. System.Text.Json, since (from my understanding) this has more to do with the format of the data rather than the actual code that produces / consumes it.

As one possible idea, if I had an instance of type NetTopologySuite.Geometries.Polygon that represents an example input to an API, I would like to give it to something from NTS and get back an instance of type Microsoft.OpenApi.Models.OpenApiSchema that represents a GeoJSON Polygon's schema, and has the .Example property filled out with the example data that I gave it.

airbreather commented 4 years ago

See also: https://docs.microsoft.com/en-us/aspnet/core/tutorials/getting-started-with-swashbuckle

airbreather commented 4 years ago

Hm, this can get quite complex. Someone may want to be able to say something like, "this parameter may be any GeoJSON object type, but if it's a Point, then it needs to look like this; if it's a LineString, then it needs to look like this; if it's a FeatureCollection, then it needs to look like this".

Immediately off the top of my head, an API to make that work might look something like:

var mySchema = new GeoJsonApiSchemaBuilder()
    .GeneralOptions(options =>
        {
            // options is of type GeoJsonGeneralOpenApiOptions
            options.AllowedGeoJsonTypes.Clear();
            options.AllowedGeoJsonTypes.Add("Point");
            options.AllowedGeoJsonTypes.Add("LineString");
            options.AllowedGeoJsonTypes.Add("Polygon");
            options.AllowedGeoJsonTypes.Add("MultiPoint");
            options.AllowedGeoJsonTypes.Add("MultiLineString");
            options.AllowedGeoJsonTypes.Add("MultiPolygon");
            options.AllowedGeoJsonTypes.Add("Feature");
            options.AllowedGeoJsonTypes.Add("FeatureCollection");

            options.GeoJsonTypesRequiringBoundingBox.Clear();
            options.GeoJsonTypesRequiringBoundingBox.Add("LineString");
            options.GeoJsonTypesRequiringBoundingBox.Add("Polygon");
            options.GeoJsonTypesRequiringBoundingBox.Add("MultiPoint");
            options.GeoJsonTypesRequiringBoundingBox.Add("MultiLineString");
            options.GeoJsonTypesRequiringBoundingBox.Add("MultiPolygon");
            options.GeoJsonTypesRequiringBoundingBox.Add("Feature");
            options.GeoJsonTypesRequiringBoundingBox.Add("FeatureCollection");
        })
    .GeometryOptions(options =>
        {
            // options is of type GeoJsonGeometryOpenApiOptions
            options.AllowEmpty = false;
            options.MaxDimension = 2;
            options.ValidBounds = new Envelope(-180, 180, -85.05115, 85.05115);
        })
    .LineStringOptions(options =>
        {
            // options is of type GeoJsonLineStringOpenApiOptions
            // GeoJsonLineStringOpenApiOptions inherits from GeoJsonGeometryOpenApiOptions
            options.MinPointCount = 5; // implies AllowEmpty = false
        })
    .PolygonOptions(options =>
        {
            // options is of type GeoJsonPolygonOpenApiOptions
            // GeoJsonPolygonOpenApiOptions inherits from GeoJsonGeometryOpenApiOptions
            options.MaxRingCount = 1;

            options.RingOrder = RingOrders.SingleExteriorThenAllInterior;
            ////options.RingOrder = RingOrders.Unspecified;

            options.ExteriorRingOrientation = OrientationIndex.CounterClockwise;
            options.InteriorRingOrientation = OrientationIndex.Clockwise;

            options.ExteriorRingOptions(ringOptions =>
                {
                    // ringOptions is of type GeoJsonExteriorRingOpenApiOptions
                    // GeoJsonExteriorRingOpenApiOptions inherits from GeoJsonLinearRingOpenApiOptions
                    // GeoJsonLinearRingOpenApiOptions inherits from GeoJsonLineStringOpenApiOptions
                    ringOptions.MinPointCount = 7; // implies AllowEmpty = false
                    ringOptions.Orientation = OrientationIndex.CounterClockwise;
                });

            options.InteriorRingOptions(ringOptions =>
                {
                    // ringOptions is of type GeoJsonInteriorRingOpenApiOptions
                    // GeoJsonInteriorRingOpenApiOptions inherits from GeoJsonLinearRingOpenApiOptions
                    // GeoJsonLinearRingOpenApiOptions inherits from GeoJsonLineStringOpenApiOptions
                    ringOptions.Orientation = OrientationIndex.Clockwise;
                });
        })
    .MultiPointOptions(options =>
        {
            // options is of type GeoJsonMultiPointOpenApiOptions
            // GeoJsonMultiPointOpenApiOptions inherits from GeoJsonGeometryOpenApiOptions
            options.MinPointCount = 2; // implies AllowEmpty = false
            options.PointOptions(pointOptions =>
                {
                    // pointOptions is of type GeoJsonPointOpenApiOptions
                    // GeoJsonPointOpenApiOptions inherits from GeoJsonGeometryOpenApiOptions
                });
        })
    /* omitted: .MultiLineStringOptions, .MultiPolygonOptions, they look the same */
    .GeometryCollectionOptions(options =>
        {
            // options is of type GeoJsonGeometryCollectionOpenApiOptions
            // GeoJsonGeometryCollectionOpenApiOptions inherits from GeoJsonGeometryOpenApiOptions
            options.MinGeometryCount = 3; // implies AllowEmpty = false

            options.AllowedGeoJsonTypes.Clear();
            options.AllowedGeoJsonTypes.Add("Point");
            options.AllowedGeoJsonTypes.Add("LineString");
            options.AllowedGeoJsonTypes.Add("Polygon");
            options.AllowedGeoJsonTypes.Add("MultiPoint");
            options.AllowedGeoJsonTypes.Add("MultiLineString");
            options.AllowedGeoJsonTypes.Add("MultiPolygon");
            options.AllowedGeoJsonTypes.Add("Feature");
            options.AllowedGeoJsonTypes.Add("FeatureCollection");

            options
                .PointOptions(pointOptions =>
                    {
                        // pointOptions is of type GeoJsonPointOpenApiOptions
                    })
                .LineStringOptions(lineStringOptions =>
                    {
                        // lineStringOptions is of type GeoJsonLineStringOpenApiOptions
                    })
                .PolygonOptions(polygonOptions =>
                    {
                        // polygonOptions is of type GeoJsonPolygonOpenApiOptions
                    });
        })
    .FeatureOptions(options =>
        {
            // options is of type GeoJsonFeatureOpenApiOptions
            options.AddRequiredProperty("foo", new OpenApiSchema { /* stuff */ }); // implies RequireProperties = true
            options.AddOptionalProperty("bar", new OpenApiSchema { /* stuff */ });

            options.GeometryOptions(geometryOptions =>
                {
                    // geometryOptions is of type GeoJsonFeatureGeometryOpenApiOptions
                    // GeoJsonFeatureGeometryOpenApiOptions inherits from GeoJsonGeometryOpenApiOptions
                    geometryOptions.AllowEmpty = true;
                    geometryOptions.AllowedGeoJsonTypes.Clear();
                    geometryOptions.AllowedGeoJsonTypes.Add("Polygon");

                    geometryOptions.PolygonOptions(polygonOptions =>
                        {
                            // polygonOptions is of type GeoJsonPolygonOpenApiOptions
                        });
                });
        })
    .FeatureCollectionOptions(options =>
        {
            // options is of type GeoJsonFeatureCollectionOpenApiOptions
            options.MinFeatureCount = 2;

            options.FeatureOptions(featureOptions =>
                {
                    // featureOptions is of type GeoJsonFeatureOpenApiOptions
                });
        })
    .WithExample(somePolygon)
    .WithDefault(someOtherPolygon)
    .Build();
airbreather commented 3 years ago

Of course, that API blueprint is something that contains a bunch of stuff, most of which wouldn't be used in a typical caller. More typically, we'd see something more like:

var mySchema = new GeoJsonApiSchemaBuilder()
    .GeneralOptions(options =>
        {
            options.AllowedGeoJsonTypes.Clear();
            options.AllowedGeoJsonTypes.Add("Polygon");
        })
    .GeometryOptions(options =>
        {
            options.AllowEmpty = false;
        }
    .WithExample(somePolygon)
    .WithDefault(someOtherPolygon)
    .Build();

Perhaps still too much, since "I just want a non-empty polygon, here's my example and default" seems like a pretty darn straightforward thing to want, for such a not-straightforward way to specify it...

djodadof2 commented 2 years ago

Any progress on this issue? I'm building a .net 6 web api and the metadata from swagger doesn't match the schema that the api actually returns when I use GeoJSON4STJ. E.g. when an object has a Point the service serializes it like this...

"geography": { "type": "Point", "coordinates": [ -122.084, 37.4219983333333 ] }, but the client can't deserialize it because the Geometry schema doesn't remotely match what the api sends.

Alternatively, advice on how to configure swagger to use the same serializer so that it presents the correct schema in the openapi metadata would be just as good.

Another option is to let the native serializer do its thing. Configuring it as much as I've been able gets me as far as an exception that it's trying to serialize an empty Point, when in fact the only Point is non-empty. E.g. using

builder.Services .AddControllers() .AddJsonOptions(options => { options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals; options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.Preserve; }); The api gives an exception like System.ArgumentOutOfRangeException: Specified argument was out of the range of valid values. (Parameter 'X called on empty Point')

kolyanch commented 2 years ago

I am currently developing a project for publishing GeoJSON based on the OGC API Features standard (https://github.com/sam-is/OgcApi.Net).

GeoJSON4STJ and .NET 5 are used.

To define metadata for geometry and properties, I abandoned the description that Swashbuckle does by default.

Now the OpenAPI description is done manually:

  1. Created a class to generate an OpenAPI description of all endpoints using Microsoft.OpenApi.
  2. A separate API method is used to publish the OpenAPI JSON.
  3. I don't use AddSwaggerGen from Swashbuckle. Instead, Swagger uses the created endpoint.

Theoretically, the creation of an OpenAPI description of GeoJSON can be separated and changed to support the required functionality.

djodadof2 commented 2 years ago

I'm starting to think the only problem here is that some NTS classes that inherit from Geometry have properties that throw an exception when their getters are accessed. E.g. a Point has all the properties of a Geometry, but some of them will throw an exception when they are accessed because they just don't apply to a Point. The GeoJSON4STJ library handles this by only serializing the properties of an object that have valid values, but that leaves the openapi metadata incompatible with what the API actually serializes so that a client won't be able to deserialize it.

spaasis commented 2 months ago

Has there been any progress on this on any front?

To avoid polluting the openapi spec I ended up stubbing out the response for a specific type of GeoJSON that I know to be a Point with specific properties:

public class StationFeatureCollection {
        public string Type { get; set; } = null!;
        public List<Feature> Features { get; set; } = new List<Feature>();

        public class Feature {
            public string Type { get; set; } = null!;
            public Geometry Geometry { get; set; } = null!;
            public Properties Properties { get; set; }= null!;
        }

        public class Geometry
        {
            public string Type { get; set; }= null!;
            public List<double> Coordinates { get; set; } = new List<double>();
        }

        public class Properties
        {
            public int StationId { get; set; }
            public string Name { get; set; } = null!;
            public string? StationCode { get; set; }
        }
    }

And then using [ProducesResponseType<StationFeatureCollection>((int)HttpStatusCode.OK)]

But this is of course not easily generalizable..