Havunen / SystemTextJsonPatch

SystemTextJsonPatch is a JSON Patch (JsonPatchDocument) RFC 6902 implementation for .NET using System.Text.Json
MIT License
102 stars 12 forks source link

Several improvements #20

Open senketsu03 opened 1 year ago

senketsu03 commented 1 year ago

The JsonPatchDocument<T> as an argument to a PATCH method on server is not recognized on swagger/redoc page and shown as {}.

The way to overcome this issue it to use List<Operation<T>> as argument instead and initialize JsonPatchDocument<T> from it in patch method:

public async Task<ActionResult<User>> PatchUserAsync(int id, List<Operation<User>> operations)
{
    var patch = new JsonPatchDocument<User>(operations, new());

    // some request  logics

    patch.ApplyTo(update);

    // more request logic
}

Sending collection of operations as application/json-patch+json works fine too. The problem is: initializing list of Operations isn't very convenient. Instead of:

var patch = new JsonPatchDocument<User>();
patch.Replace((u) => u.Name, "Tom");
patch.Replace((u) => u.Age, 40);

We have to write such code:

var operations = new List<Operation<User>>
{
    new Operation<User>("replace", "/name", null, "Tom"),
    new Operation<User>("replace", "/age", null, 40)
};

The problems of this code are obvious: we have to rely on string values when creating the operations (while it could have been safer to use OperationType enum) and we have to rely on string when resolving path.

Probably a static methods for Operation<T> class could be implemented, so the usage would look similar to this:

var operations = new List<Operation<User>>
{
    Operation<User>.Replace((u) => u.Name, "Tom"),
    Operation<User>.Replace((u) => u.Age, 40)
};

These static methods would also simplify related calls in JsonPatchDocumentOfT:

https://github.com/Havunen/SystemTextJsonPatch/blob/c7ffbaf82e62bf860c9ac120665dbe3318dc0dfc/SystemTextJsonPatch/JsonPatchDocumentOfT.cs#L189

kipusoep commented 1 year ago

Just ran into the empty schema for PatchDocument in swagger, would love to have this supported properly OOTB.

Havunen commented 1 year ago

Yeah we could quite easily improve it to a point where swagger schema gets generated as path - string, op - string, but what can we do for the value property as that could be anything?

kipusoep commented 1 year ago

Good question, I've chosen to use a string as well at the moment, as suggested here: https://stackoverflow.com/a/65607728/510149

/// <summary>
/// A swagger document filter to support json patch better.
/// </summary>
public class JsonPatchDocumentFilter : IDocumentFilter
{
    /// <inheritdoc />
    public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
    {
        // Replace schemas for Operation and JsonPatchDocument
        var schemas = swaggerDoc.Components.Schemas.ToList();
        foreach (var item in schemas)
        {
            if (item.Key.StartsWith("Operation") || item.Key.StartsWith("JsonPatchDocument"))
            {
                swaggerDoc.Components.Schemas.Remove(item.Key);
            }
        }

        var jsonPatchDocumentOperationTypes = Enum.GetValues<PatchOperationType>()
            .Where(x => x != PatchOperationType.Invalid)
            .Select(x => new OpenApiString(x.ToString().ToLower())).ToList();
        swaggerDoc.Components.Schemas.Add("Operation", new OpenApiSchema
        {
            Type = "object",
            Properties = new Dictionary<string, OpenApiSchema>
            {
                { "op", new OpenApiSchema { Type = "string", Enum = new List<IOpenApiAny>(jsonPatchDocumentOperationTypes) } },
                { "value", new OpenApiSchema { Type = "string" } },
                { "path", new OpenApiSchema { Type = "string" } },
            },
        });
        swaggerDoc.Components.Schemas.Add("JsonPatchDocument", new OpenApiSchema
        {
            Type = "array",
            Items = new OpenApiSchema
            {
                Reference = new OpenApiReference { Type = ReferenceType.Schema, Id = "Operation" },
            },
            Description = "Array of operations to perform",
        });

        // Alter the content type for patch requests
        foreach (var path in swaggerDoc.Paths
                     .SelectMany(p => p.Value.Operations)
                     .Where(p => p.Key == OperationType.Patch))
        {
            path.Value.RequestBody.Content = new Dictionary<string, OpenApiMediaType>
            {
                { "application/json-patch+json", new OpenApiMediaType { Schema = new OpenApiSchema { Reference = new OpenApiReference { Type = ReferenceType.Schema, Id = "JsonPatchDocument" } } } },
            };
        }
    }
}
Havunen commented 1 year ago

Maybe a new Nuget package needs to be created to avoid dependency to Swashbuckle / NSwag

kipusoep commented 1 year ago

Currently I'm also struggling getting a bulk patch endpoint to work, with a IDictionary<Guid, JsonPatchDocument<MyWriteDtoClass>> patchDocuments parameter.

kipusoep commented 1 year ago

This works when used with a parameter like [FromBody] IDictionary<Guid, JsonPatchDocument<WeatherForecast>> weatherForecasts:

/// <summary>
/// A swagger document filter to support json patch better.
/// </summary>
public class JsonPatchDocumentFilter : IDocumentFilter
{
    /// <inheritdoc />
    public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
    {
        // Replace schemas for Operation and JsonPatchDocument
        swaggerDoc.Components.Schemas
            .Where(x => x.Key.EndsWith("Operation") || x.Key.EndsWith("JsonPatchDocument"))
            .ToList()
            .ForEach(x => swaggerDoc.Components.Schemas.Remove(x));

        var jsonPatchDocumentOperationTypes = Enum.GetValues<PatchOperationType>()
            .Where(x => x != PatchOperationType.Invalid)
            .Select(x => new OpenApiString(x.ToString().ToLower())).ToList();
        swaggerDoc.Components.Schemas.Add("Operation", new OpenApiSchema
        {
            Type = "object",
            Properties = new Dictionary<string, OpenApiSchema>
            {
                { "op", new OpenApiSchema { Type = "string", Enum = new List<IOpenApiAny>(jsonPatchDocumentOperationTypes) } },
                { "value", new OpenApiSchema { Type = "string" } },
                { "path", new OpenApiSchema { Type = "string" } },
            },
        });
        swaggerDoc.Components.Schemas.Add("JsonPatchDocument", new OpenApiSchema
        {
            Type = "array",
            Items = new OpenApiSchema
            {
                Reference = new OpenApiReference { Type = ReferenceType.Schema, Id = "Operation" },
            },
            Description = "Array of operations to perform",
        });
        swaggerDoc.Components.Schemas.Add("BulkJsonPatchDocument", new OpenApiSchema
        {
            Type = "object",
            AdditionalProperties = new OpenApiSchema
            {
                Type = "array",
                Items = new OpenApiSchema
                {
                    Reference = new OpenApiReference { Type = ReferenceType.Schema, Id = "Operation" },
                },
            },
            Description = "A dictionary/map using the entity id (Guid) as key and JsonPatchDocument as value.",
        });

        // Alter the content type and schema for patch requests
        foreach (var path in swaggerDoc.Paths
                     .SelectMany(p => p.Value.Operations)
                     .Where(p => p.Key == OperationType.Patch))
        {
            var schemaReferenceId = "JsonPatchDocument";
            if (path.Value.RequestBody.Content.First().Value.Schema.AdditionalProperties != null)
            {
                // When AdditionalProperties is not null, it means a dictionary is used and thus it's a bulk request
                schemaReferenceId = "BulkJsonPatchDocument";
            }

            path.Value.RequestBody.Content = new Dictionary<string, OpenApiMediaType>
            {
                {
                    "application/json-patch+json",
                    new OpenApiMediaType
                    {
                        Schema = new OpenApiSchema
                        {
                            Reference = new OpenApiReference
                            {
                                Type = ReferenceType.Schema,
                                Id = schemaReferenceId,
                            },
                        },
                    }
                },
            };
        }
    }
}
leoerlandsson commented 5 months ago

Here's the IDocumentFilter that we use successfully, inspired by the code examples on this Issue and in the linked StackOverflow thread.

public class JsonPatchDocumentFilter : IDocumentFilter
{
    public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
    {
        // Handle schemas
        var keysToRemove = swaggerDoc.Components.Schemas
            .Where(s =>
                (s.Key.EndsWith("Operation", StringComparison.OrdinalIgnoreCase) && s.Value.Properties.All(p => new string[] { "op", "path", "from", "value" }.Contains(p.Key))) ||
                (s.Key.EndsWith("JsonPatchDocument", StringComparison.OrdinalIgnoreCase))
            )
            .Select(s => s.Key)
            .ToList();

        foreach (var key in keysToRemove)
        {
            swaggerDoc.Components.Schemas.Remove(key);
        }

        swaggerDoc.Components.Schemas.Add("JsonPatchOperation", new OpenApiSchema
        {
            Type = "object",
            Description = "Describes a single operation in a JSON Patch document. Includes the operation type, the target property path, and the value to be used.",
            Required = new HashSet<string> { "op", "path", "value" },
            Properties = new Dictionary<string, OpenApiSchema>
            {
                {
                    "op", new OpenApiSchema
                    {
                        Type = "string",
                        Description = "The operation type. Allowed values: 'add', 'remove', 'replace', 'move', 'copy', 'test'.",
                        Enum = new List<IOpenApiAny>
                        {
                            new OpenApiString("add"),
                            new OpenApiString("remove"),
                            new OpenApiString("replace"),
                            new OpenApiString("move"),
                            new OpenApiString("copy"),
                            new OpenApiString("test")
                        }
                    }
                },
                {
                    "path", new OpenApiSchema
                    {
                        Type = "string",
                        Description = "The JSON Pointer path to the property in the target document where the operation is to be applied.",
                    }
                },
                {
                    "from", new OpenApiSchema
                    {
                        Type = "string",
                        Description = "Should be a path, required when using move, copy",
                    }
                },
                {
                    "value", new OpenApiSchema
                    {
                        Nullable = true,
                        Description = "The value to apply for 'add', 'replace', or 'test' operations. Not required for 'remove', 'move', or 'copy'.",
                    }
                },
            },
        });

        swaggerDoc.Components.Schemas.Add("JsonPatchDocument", new OpenApiSchema
        {
            Type = "array",
            Items = new OpenApiSchema
            {
                Reference = new OpenApiReference { Type = ReferenceType.Schema, Id = "JsonPatchOperation" }
            },
            Description = "Array of operations to perform"
        });

        // Handle paths
        foreach (var path in swaggerDoc.Paths)
        {
            if (path.Value.Operations.TryGetValue(OperationType.Patch, out var patchOperation) && patchOperation.RequestBody != null)
            {
                foreach (var key in patchOperation.RequestBody.Content.Keys)
                {
                    patchOperation.RequestBody.Content.Remove(key);
                }

                patchOperation.RequestBody.Content.Add("application/json-patch+json", new OpenApiMediaType
                {
                    Schema = new OpenApiSchema
                    {
                        Reference = new OpenApiReference { Type = ReferenceType.Schema, Id = "JsonPatchDocument" },
                    },
                });
            }
        }
    }