Skip to content
This repository has been archived by the owner on Aug 2, 2024. It is now read-only.

(#252) Document Filter for integrating datasync controllers into Swashbuckle. #521

Merged
merged 3 commits into from
Dec 14, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions sdk/dotnet/Datasync.Framework.sln
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,11 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Datasy
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Datasync.LiteDb.Test", "test\Microsoft.AspNetCore.Datasync.LiteDb.Test\Microsoft.AspNetCore.Datasync.LiteDb.Test.csproj", "{6E13D6AC-BD68-4362-A352-493729091D66}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.Datasync.Automapper", "src\Microsoft.AspNetCore.Datasync.Automapper\Microsoft.AspNetCore.Datasync.Automapper.csproj", "{21B48FE5-73B5-4380-9FAD-3F0272120110}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Datasync.Automapper", "src\Microsoft.AspNetCore.Datasync.Automapper\Microsoft.AspNetCore.Datasync.Automapper.csproj", "{21B48FE5-73B5-4380-9FAD-3F0272120110}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.Datasync.Automapper.Test", "test\Microsoft.AspNetCore.Datasync.Automapper.Test\Microsoft.AspNetCore.Datasync.Automapper.Test.csproj", "{05FD004E-D69F-4E38-A561-B87B27815BF6}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Datasync.Automapper.Test", "test\Microsoft.AspNetCore.Datasync.Automapper.Test\Microsoft.AspNetCore.Datasync.Automapper.Test.csproj", "{05FD004E-D69F-4E38-A561-B87B27815BF6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.Datasync.Swashbuckle", "src\Microsoft.AspNetCore.Datasync.Swashbuckle\Microsoft.AspNetCore.Datasync.Swashbuckle.csproj", "{94F09CBD-6B48-48CE-8B32-E3A80E13962A}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Expand Down Expand Up @@ -123,6 +125,10 @@ Global
{05FD004E-D69F-4E38-A561-B87B27815BF6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{05FD004E-D69F-4E38-A561-B87B27815BF6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{05FD004E-D69F-4E38-A561-B87B27815BF6}.Release|Any CPU.Build.0 = Release|Any CPU
{94F09CBD-6B48-48CE-8B32-E3A80E13962A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{94F09CBD-6B48-48CE-8B32-E3A80E13962A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{94F09CBD-6B48-48CE-8B32-E3A80E13962A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{94F09CBD-6B48-48CE-8B32-E3A80E13962A}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -149,6 +155,7 @@ Global
{6E13D6AC-BD68-4362-A352-493729091D66} = {897BFB53-D437-4D6F-A581-DF708C560545}
{21B48FE5-73B5-4380-9FAD-3F0272120110} = {1675CF58-54FE-474F-80AD-8E494662F2A4}
{05FD004E-D69F-4E38-A561-B87B27815BF6} = {897BFB53-D437-4D6F-A581-DF708C560545}
{94F09CBD-6B48-48CE-8B32-E3A80E13962A} = {1675CF58-54FE-474F-80AD-8E494662F2A4}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {A0CEDA72-A2EB-4741-98C3-6E5CFA65DF02}
Expand Down
1 change: 1 addition & 0 deletions sdk/dotnet/SignList.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<FirstParty Include="Microsoft.AspNetCore.Datasync.LiteDb.dll"/>
<FirstParty Include="Microsoft.AspNetCore.Datasync.Abstractions.dll"/>
<FirstParty Include="Microsoft.AspNetCore.Datasync.Automapper.dll"/>
<FirstParty Include="Microsoft.AspNetCore.Datasync.Swashbuckle.dll"/>

<!-- Datasync Client SDK -->
<FirstParty Include="Microsoft.Datasync.Client.dll"/>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
using Microsoft.AspNetCore.Datasync;
using Microsoft.AspNetCore.Mvc;
using Microsoft.OpenApi.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;

namespace Swashbuckle.AspNetCore.SwaggerGen
{
/// <summary>
/// An <see cref="IDocumentFilter"/> that adds the relevant schema and paramter definitions
/// to generate an OpenAPI v3.0.3 definition for Datasync <see cref="TableController{TEntity}"/>
/// controllers.
/// </summary>
public class DatasyncDocumentFilter : IDocumentFilter
{
/// <summary>
/// The list of operation types.
/// </summary>
private enum OpType
{
Create,
Delete,
GetById,
List,
Patch,
Replace
}

/// <summary>
/// The list of entity names that have already had their schema adjusted.
/// </summary>
private readonly List<string> processedEntityNames = new();

/// <summary>
/// Applies the necessary changes to the <see cref="OpenApiDocument"/>.
/// </summary>
/// <param name="document">The <see cref="OpenApiDocument"/> to edit.</param>
/// <param name="context">The filter context.</param>
public void Apply(OpenApiDocument document, DocumentFilterContext context)
{
var controllers = Assembly.GetExecutingAssembly().GetTypes()
.Where(t => t.BaseType?.IsGenericType == true && t.BaseType.GetGenericTypeDefinition() == typeof(TableController<>))
.ToList();

foreach (Type controller in controllers)
{
if (controller == null)
continue;

var entityType = controller.BaseType?.GenericTypeArguments[0];
if (entityType == null)
continue;

var routeAttribute = controller.GetCustomAttribute<RouteAttribute>();
if (routeAttribute == null)
continue;
var allEntitiesPath = $"/{routeAttribute.Template}";
var singleEntityPath = $"/{routeAttribute.Template}/{{id}}";

// Get the various operations
Dictionary<OpType, OpenApiOperation> operations = new()
{
{ OpType.Create, document.Paths[allEntitiesPath].Operations[OperationType.Post] },
{ OpType.Delete, document.Paths[singleEntityPath].Operations[OperationType.Delete] },
{ OpType.GetById, document.Paths[singleEntityPath].Operations[OperationType.Get] },
{ OpType.List, document.Paths[allEntitiesPath].Operations[OperationType.Get] },
{ OpType.Patch, document.Paths[singleEntityPath].Operations[OperationType.Patch] },
{ OpType.Replace, document.Paths[singleEntityPath].Operations[OperationType.Put] }
};

// Make the system properties in the entity read-only
if (!processedEntityNames.Contains(entityType.Name))
{
context.SchemaRepository.Schemas[entityType.Name].MakeSystemPropertiesReadonly();
context.SchemaRepository.Schemas[entityType.Name].UnresolvedReference = false;
context.SchemaRepository.Schemas[entityType.Name].Reference = new OpenApiReference
{
Id = entityType.Name,
Type = ReferenceType.Schema
};
}

Type listEntityType = typeof(Page<>).MakeGenericType(entityType);
OpenApiSchema listSchemaRef = context.SchemaRepository.Schemas.GetValueOrDefault(listEntityType.Name)
?? context.SchemaGenerator.GenerateSchema(listEntityType, context.SchemaRepository);

foreach (var operation in operations)
{
// All Operations must have an ZUMO-API-VERSION header as a parameter
operation.Value.AddZumoApiVersionHeader();

// Each operation also has certain modifications.
switch (operation.Key)
{
case OpType.Create:
// Request Edits
operation.Value.AddConditionalHeader(true);

// Response Edits
operation.Value.AddResponseWithContent("201", "Created", context.SchemaRepository.Schemas[entityType.Name]);
operation.Value.Responses["400"] = new OpenApiResponse { Description = "Bad Request" };
operation.Value.AddConflictResponse(context.SchemaRepository.Schemas[entityType.Name]);
break;

case OpType.Delete:
// Request Edits
operation.Value.AddConditionalHeader();

// Response Edits
operation.Value.Responses["204"] = new OpenApiResponse { Description = "No Content" };
operation.Value.Responses["404"] = new OpenApiResponse { Description = "Not Found" };
operation.Value.Responses["410"] = new OpenApiResponse { Description = "Gone" };
operation.Value.AddConflictResponse(context.SchemaRepository.Schemas[entityType.Name]);
break;

case OpType.GetById:
// Request Edits
operation.Value.AddConditionalHeader(true);

// Response Edits
operation.Value.AddResponseWithContent("200", "OK", context.SchemaRepository.Schemas[entityType.Name]);
operation.Value.Responses["304"] = new OpenApiResponse { Description = "Not Modified" };
operation.Value.Responses["404"] = new OpenApiResponse { Description = "Not Found" };
break;

case OpType.List:
// Request Edits
operation.Value.AddODataQueryParameters();

// Response Edits
operation.Value.AddResponseWithContent("200", "OK", listSchemaRef);
operation.Value.Responses["400"] = new OpenApiResponse { Description = "Bad Request" };
break;

case OpType.Patch:
// Request Edits
operation.Value.AddConditionalHeader();

// Response Edits
operation.Value.AddResponseWithContent("200", "OK", context.SchemaRepository.Schemas[entityType.Name]);
operation.Value.Responses["400"] = new OpenApiResponse { Description = "Bad Request" };
operation.Value.Responses["404"] = new OpenApiResponse { Description = "Not Found" };
operation.Value.Responses["410"] = new OpenApiResponse { Description = "Gone" };
operation.Value.AddConflictResponse(context.SchemaRepository.Schemas[entityType.Name]);
break;

case OpType.Replace:
// Request Edits
operation.Value.AddConditionalHeader();

// Response Edits
operation.Value.AddResponseWithContent("200", "OK", context.SchemaRepository.Schemas[entityType.Name]);
operation.Value.Responses["400"] = new OpenApiResponse { Description = "Bad Request" };
operation.Value.Responses["404"] = new OpenApiResponse { Description = "Not Found" };
operation.Value.Responses["410"] = new OpenApiResponse { Description = "Gone" };
operation.Value.AddConflictResponse(context.SchemaRepository.Schemas[entityType.Name]);
break;
}
}
}
}

/// <summary>
/// A type representing a single page of entities.
/// </summary>
/// <typeparam name="T">The type of the entity.</typeparam>
internal class Page<T>
{
/// <summary>
/// The list of entities in this page of the results.
/// </summary>
public IEnumerable<T> Items { get; } = new List<T>();

/// <summary>
/// The count of all the entities in the result set.
/// </summary>
public long? Count { get; }

/// <summary>
/// The URI to the next page of entities.
/// </summary>
public Uri NextLink { get; }
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
using Microsoft.OpenApi.Any;
using System.Collections.Generic;
using System.Linq;

namespace Microsoft.OpenApi.Models
{
/// <summary>
/// A set of extension methods for working with the <see cref="OpenApiOperation"/> class.
/// </summary>
internal static class OpenApiDatasyncExtensions
{
private const string JsonMediaType = "application/json";
private static string[] SystemProperties = { "updatedAt", "version", "deleted" };

/// <summary>
/// Adds a <c>ZUMO-API-VERSION</c> header parameter to the operation document.
/// </summary>
/// <param name="operation">The <see cref="OpenApiOperation"/> reference.</param>
internal static void AddZumoApiVersionHeader(this OpenApiOperation operation)
{
var versionStrings = new string[] { "3.0.0", "2.0.0" };

operation.Parameters.Add(new OpenApiParameter
{
Name = "ZUMO-API-VERSION",
Description = "Datasync protocol version to be used.",
In = ParameterLocation.Header,
Required = true,
Schema = new OpenApiSchema
{
Type = "enum",
Enum = versionStrings.Select(p => new OpenApiString(p)).ToList<IOpenApiAny>()
}
});
}

/// <summary>
/// Adds an appropriate conditional header (either <c>If-Match</c> or <c>If-None-Match</c> to the list of
/// allowed headers.
/// </summary>
/// <param name="operation">The <see cref="OpenApiOperation"/> reference.</param>
/// <param name="ifNoneMatch">If <c>true</c>, add a <c>If-None-Match</c> header.</param>
internal static void AddConditionalHeader(this OpenApiOperation operation, bool ifNoneMatch = false)
{
var headerName = ifNoneMatch ? "If-None-Match" : "If-Match";
var description = ifNoneMatch
? "Conditionally execute only if the entity version does not match the provided string (RFC 9110 13.1.2)."
: "Conditionally execute only if the entity version matches the provided string (RFC 9110 13.1.1).";

operation.Parameters.Add(new OpenApiParameter
{
Name = headerName,
Description = description,
In = ParameterLocation.Header,
Required = false,
Schema = new OpenApiSchema { Type = "string" }
});
}

/// <summary>
/// Adds the OData query parameter to the operation.
/// </summary>
/// <param name="operation">The <see cref="OpenApiOperation"/> reference.</param>
/// <param name="parameterName">The name of the query parameter.</param>
/// <param name="parameterType">The OpenAPI type for the query parameter.</param>
/// <param name="description">The OpenAPI description for the query parameter.</param>
internal static void AddODataQueryParameter(this OpenApiOperation operation, string parameterName, string parameterType, string description)
{
operation.Parameters.Add(new OpenApiParameter
{
Name = parameterName,
Description = description,
In = ParameterLocation.Query,
Required = false,
Schema = new OpenApiSchema { Type = parameterType }
});
}

/// <summary>
/// Adds the OData query parameters for the <c>GET list</c> operation.
/// </summary>
/// <param name="operation">The <see cref="OpenApiOperation"/> reference.</param>
internal static void AddODataQueryParameters(this OpenApiOperation operation)
{
operation.AddODataQueryParameter("$count", "boolean", "If true, return the total number of items matched by the filter");
operation.AddODataQueryParameter("$filter", "string", "An OData filter describing the entities to be returned");
operation.AddODataQueryParameter("$orderby", "string", "A comma-separated list of ordering instructions. Each ordering instruction is a field name with an optional direction (asc or desc).");
operation.AddODataQueryParameter("$select", "string", "A comma-separated list of fields to be returned in the result set.");
operation.AddODataQueryParameter("$skip", "integer", "The number of items in the list to skip for paging support.");
operation.AddODataQueryParameter("$top", "integer", "The number of items in the list to return for paging support.");
operation.AddODataQueryParameter("__includedeleted", "boolean", "If true, soft-deleted items are returned as well as non-deleted items.");
}

/// <summary>
/// Adds or replaces a response with JSON content and an ETag.
/// </summary>
/// <param name="operation">The <see cref="OpenApiOperation"/> to modify.</param>
/// <param name="statusCode">The HTTP status code to model.</param>
/// <param name="description">The description of the HTTP status code.</param>
/// <param name="schema">The schema of the entity to return.</param>
internal static void AddResponseWithContent(this OpenApiOperation operation, string statusCode, string description, OpenApiSchema schema)
{
var response = new OpenApiResponse
{
Description = description,
Content = new Dictionary<string, OpenApiMediaType>
{
[JsonMediaType] = new OpenApiMediaType { Schema = schema }
}
};
var etagDescription = statusCode == "409" || statusCode == "412"
? "The opaque versioning identifier of the conflicting entity"
: "The opaque versioning identifier of the entity";
response.Headers.Add("ETag", new OpenApiHeader
{
Schema = new OpenApiSchema { Type = "string" },
Description = $"{etagDescription}, per RFC 9110 8.8.3."
});
operation.Responses[statusCode] = response;
}

/// <summary>
/// Adds or replaces the 409/412 Conflict/Precondition Failed response.
/// </summary>
/// <param name="operation">The <see cref="OpenApiOperation"/> to modify.</param>
/// <param name="schema">The schema of the entity to return.</param>
internal static void AddConflictResponse(this OpenApiOperation operation, OpenApiSchema schema)
{
operation.AddResponseWithContent("409", "Conflict", schema);
operation.AddResponseWithContent("412", "Precondition failed", schema);
}

/// <summary>
/// Makes the system properties in the schema read-only.
/// </summary>
/// <param name="schema">The <see cref="OpenApiSchema"/> to edit.</param>
public static void MakeSystemPropertiesReadonly(this OpenApiSchema schema)
{
foreach (var property in schema.Properties)
{
if (SystemProperties.Contains(property.Key))
{
property.Value.ReadOnly = true;
}
}
}
}
}
Loading