Skip to content

Commit 00a1167

Browse files
authored
(#40) Swashbuckle support. (#43)
1 parent 4b0c692 commit 00a1167

14 files changed

+1871
-2
lines changed

.github/workflows/SignedPackageFileList.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@
66
**/CommunityToolkit.Datasync.Server.InMemory
77
**/CommunityToolkit.Datasync.Server.LiteDb
88
**/CommunityToolkit.Datasync.Server.NSwag
9+
**/CommunityToolkit.Datasync.Server.Swashbuckle

Datasync.Toolkit.sln

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,13 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{2D2A6EFC
5151
.github\workflows\SignedTemplateFileList.txt = .github\workflows\SignedTemplateFileList.txt
5252
EndProjectSection
5353
EndProject
54-
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Datasync.Server.NSwag", "src\CommunityToolkit.Datasync.Server.NSwag\CommunityToolkit.Datasync.Server.NSwag.csproj", "{C56A5630-03D7-43EF-BC09-69CA97152CFC}"
54+
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CommunityToolkit.Datasync.Server.NSwag", "src\CommunityToolkit.Datasync.Server.NSwag\CommunityToolkit.Datasync.Server.NSwag.csproj", "{C56A5630-03D7-43EF-BC09-69CA97152CFC}"
5555
EndProject
56-
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Datasync.Server.NSwag.Test", "tests\CommunityToolkit.Datasync.Server.NSwag.Test\CommunityToolkit.Datasync.Server.NSwag.Test.csproj", "{983FB40E-BA00-4055-9A8A-24E1A351FB5B}"
56+
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CommunityToolkit.Datasync.Server.NSwag.Test", "tests\CommunityToolkit.Datasync.Server.NSwag.Test\CommunityToolkit.Datasync.Server.NSwag.Test.csproj", "{983FB40E-BA00-4055-9A8A-24E1A351FB5B}"
57+
EndProject
58+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Datasync.Server.Swashbuckle.Test", "tests\CommunityToolkit.Datasync.Server.Swashbuckle.Test\CommunityToolkit.Datasync.Server.Swashbuckle.Test.csproj", "{F578EC54-454F-4114-AC37-C83A7831E783}"
59+
EndProject
60+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Datasync.Server.Swashbuckle", "src\CommunityToolkit.Datasync.Server.Swashbuckle\CommunityToolkit.Datasync.Server.Swashbuckle.csproj", "{45D47A4E-AD58-40C8-B4CC-95BC888C47A7}"
5761
EndProject
5862
Global
5963
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -133,6 +137,14 @@ Global
133137
{983FB40E-BA00-4055-9A8A-24E1A351FB5B}.Debug|Any CPU.Build.0 = Debug|Any CPU
134138
{983FB40E-BA00-4055-9A8A-24E1A351FB5B}.Release|Any CPU.ActiveCfg = Release|Any CPU
135139
{983FB40E-BA00-4055-9A8A-24E1A351FB5B}.Release|Any CPU.Build.0 = Release|Any CPU
140+
{F578EC54-454F-4114-AC37-C83A7831E783}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
141+
{F578EC54-454F-4114-AC37-C83A7831E783}.Debug|Any CPU.Build.0 = Debug|Any CPU
142+
{F578EC54-454F-4114-AC37-C83A7831E783}.Release|Any CPU.ActiveCfg = Release|Any CPU
143+
{F578EC54-454F-4114-AC37-C83A7831E783}.Release|Any CPU.Build.0 = Release|Any CPU
144+
{45D47A4E-AD58-40C8-B4CC-95BC888C47A7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
145+
{45D47A4E-AD58-40C8-B4CC-95BC888C47A7}.Debug|Any CPU.Build.0 = Debug|Any CPU
146+
{45D47A4E-AD58-40C8-B4CC-95BC888C47A7}.Release|Any CPU.ActiveCfg = Release|Any CPU
147+
{45D47A4E-AD58-40C8-B4CC-95BC888C47A7}.Release|Any CPU.Build.0 = Release|Any CPU
136148
EndGlobalSection
137149
GlobalSection(SolutionProperties) = preSolution
138150
HideSolutionNode = FALSE
@@ -156,6 +168,8 @@ Global
156168
{E1AC26CF-5C5C-400C-97F6-416D6FCF5BC7} = {84AD662A-4B9E-4E64-834D-72529FB7FCE5}
157169
{C56A5630-03D7-43EF-BC09-69CA97152CFC} = {84AD662A-4B9E-4E64-834D-72529FB7FCE5}
158170
{983FB40E-BA00-4055-9A8A-24E1A351FB5B} = {D59F1489-5D74-4F52-B78B-88037EAB2838}
171+
{F578EC54-454F-4114-AC37-C83A7831E783} = {D59F1489-5D74-4F52-B78B-88037EAB2838}
172+
{45D47A4E-AD58-40C8-B4CC-95BC888C47A7} = {84AD662A-4B9E-4E64-834D-72529FB7FCE5}
159173
EndGlobalSection
160174
GlobalSection(ExtensibilityGlobals) = postSolution
161175
SolutionGuid = {78A935E9-8F14-448A-BEDF-360FB742F14E}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<Description>Provides necessary capabilities for supporting the Datasync server library when using Swashbuckle for creating OpenAPI definitions.</Description>
4+
</PropertyGroup>
5+
6+
<Import Project="..\Shared.Build.props" />
7+
8+
<ItemGroup>
9+
<InternalsVisibleTo Include="CommunityToolkit.Datasync.Server.Swashbuckle.Test" />
10+
</ItemGroup>
11+
12+
<ItemGroup>
13+
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
14+
</ItemGroup>
15+
16+
<ItemGroup>
17+
<ProjectReference Include="..\CommunityToolkit.Datasync.Server\CommunityToolkit.Datasync.Server.csproj" />
18+
</ItemGroup>
19+
</Project>
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using CommunityToolkit.Datasync.Server.Filters;
6+
using Microsoft.AspNetCore.Mvc.ApiExplorer;
7+
using Microsoft.OpenApi.Models;
8+
using Swashbuckle.AspNetCore.SwaggerGen;
9+
using System.Diagnostics.CodeAnalysis;
10+
using System.Reflection;
11+
12+
namespace CommunityToolkit.Datasync.Server.Swashbuckle;
13+
14+
/// <summary>
15+
/// An <see cref="IDocumentFilter"/> that adds the relevant schema and paramter definitions
16+
/// to generate an OpenAPI v3.0.3 definition for Datasync <see cref="TableController{TEntity}"/>
17+
/// controllers.
18+
/// </summary>
19+
/// <remarks>
20+
/// Creates a new <see cref="DatasyncDocumentFilter"/>.
21+
/// </remarks>
22+
/// <param name="assemblyToQuery">The assembly to query for TableController instances, if any. If none is provided, the calling assembly is queried.</param>
23+
public class DatasyncDocumentFilter(Assembly? assemblyToQuery = null) : IDocumentFilter
24+
{
25+
// The list of operation types.
26+
private enum OpType
27+
{
28+
Create,
29+
Delete,
30+
GetById,
31+
List,
32+
Replace
33+
}
34+
35+
// The names of the QueryAsync() and CreateAsync() methods in the TableController<T>.
36+
private const string queryMethod = nameof(TableController<ITableData>.QueryAsync);
37+
private const string createMethod = nameof(TableController<ITableData>.CreateAsync);
38+
39+
// The list of entity names that have already had their schema adjusted.
40+
private readonly List<string> processedEntityNames = [];
41+
42+
/// <summary>
43+
/// Applies the necessary changes to the <see cref="OpenApiDocument"/>.
44+
/// </summary>
45+
/// <param name="document">The <see cref="OpenApiDocument"/> to edit.</param>
46+
/// <param name="context">The filter context.</param>
47+
public void Apply(OpenApiDocument document, DocumentFilterContext context)
48+
{
49+
foreach (Type controller in GetAllTableControllers(assemblyToQuery))
50+
{
51+
if (TryGetTableEntityType(controller, out Type? entityType))
52+
{
53+
string? routePath = GetRoutePathFromContext(context, controller);
54+
if (routePath != null)
55+
{
56+
ProcessController(entityType!, routePath, document, context);
57+
}
58+
}
59+
}
60+
}
61+
62+
/// <summary>
63+
/// Applies the necessary changes to the <see cref="OpenApiDocument"/> for a single controller.
64+
/// </summary>
65+
/// <param name="entityType">The type of the entity being processed by the controller.</param>
66+
/// <param name="routePath">The path used to access the controller in a HTTP request.</param>
67+
/// <param name="document">The <see cref="OpenApiDocument"/> to edit.</param>
68+
/// <param name="context">The filter context.</param>
69+
internal void ProcessController(Type entityType, string routePath, OpenApiDocument document, DocumentFilterContext context)
70+
{
71+
// Get the base paths managed by this controller.
72+
string allEntitiesPath = $"/{routePath}";
73+
string singleEntityPath = $"/{routePath}/{{id}}";
74+
75+
// Get the various operations
76+
Dictionary<OpType, OpenApiOperation> operations = [];
77+
AddOperationIfPresent(operations, OpType.Create, document, allEntitiesPath, OperationType.Post);
78+
AddOperationIfPresent(operations, OpType.Delete, document, singleEntityPath, OperationType.Delete);
79+
AddOperationIfPresent(operations, OpType.GetById, document, singleEntityPath, OperationType.Get);
80+
AddOperationIfPresent(operations, OpType.List, document, allEntitiesPath, OperationType.Get);
81+
AddOperationIfPresent(operations, OpType.Replace, document, singleEntityPath, OperationType.Put);
82+
83+
// Make the system properties in the entity read-only
84+
if (!this.processedEntityNames.Contains(entityType.Name))
85+
{
86+
// Generate a schema for the entity if it doesn't exist.
87+
if (context.SchemaRepository.Schemas.GetValueOrDefault(entityType.Name) == null)
88+
{
89+
_ = context.SchemaGenerator.GenerateSchema(entityType, context.SchemaRepository);
90+
}
91+
92+
// This is a Datasync schema, so update the schema for the datasync attributes.
93+
context.SchemaRepository.Schemas[entityType.Name].MakeSystemPropertiesReadonly();
94+
context.SchemaRepository.Schemas[entityType.Name].UnresolvedReference = false;
95+
context.SchemaRepository.Schemas[entityType.Name].Reference = new OpenApiReference
96+
{
97+
Id = entityType.Name,
98+
Type = ReferenceType.Schema
99+
};
100+
this.processedEntityNames.Add(entityType.Name);
101+
}
102+
103+
Type listEntityType = typeof(Page<>).MakeGenericType(entityType);
104+
OpenApiSchema listSchemaRef = context.SchemaRepository.Schemas.GetValueOrDefault(listEntityType.Name)
105+
?? context.SchemaGenerator.GenerateSchema(listEntityType, context.SchemaRepository);
106+
107+
foreach (KeyValuePair<OpType, OpenApiOperation> operation in operations)
108+
{
109+
// Each operation also has certain modifications.
110+
switch (operation.Key)
111+
{
112+
case OpType.Create:
113+
// Request Edits
114+
operation.Value.AddConditionalHeader(true);
115+
116+
// Response Edits
117+
operation.Value.AddResponseWithContent("201", "Created", context.SchemaRepository.Schemas[entityType.Name]);
118+
operation.Value.Responses["400"] = new OpenApiResponse { Description = "Bad Request" };
119+
operation.Value.AddConflictResponse(context.SchemaRepository.Schemas[entityType.Name]);
120+
break;
121+
122+
case OpType.Delete:
123+
// Request Edits
124+
operation.Value.AddConditionalHeader();
125+
126+
// Response Edits
127+
operation.Value.Responses["204"] = new OpenApiResponse { Description = "No Content" };
128+
operation.Value.Responses["404"] = new OpenApiResponse { Description = "Not Found" };
129+
operation.Value.Responses["410"] = new OpenApiResponse { Description = "Gone" };
130+
operation.Value.AddConflictResponse(context.SchemaRepository.Schemas[entityType.Name]);
131+
break;
132+
133+
case OpType.GetById:
134+
// Request Edits
135+
operation.Value.AddConditionalHeader(true);
136+
137+
// Response Edits
138+
operation.Value.AddResponseWithContent("200", "OK", context.SchemaRepository.Schemas[entityType.Name]);
139+
operation.Value.Responses["304"] = new OpenApiResponse { Description = "Not Modified" };
140+
operation.Value.Responses["404"] = new OpenApiResponse { Description = "Not Found" };
141+
break;
142+
143+
case OpType.List:
144+
// Request Edits
145+
operation.Value.AddODataQueryParameters();
146+
147+
// Response Edits
148+
operation.Value.AddResponseWithContent("200", "OK", listSchemaRef);
149+
operation.Value.Responses["400"] = new OpenApiResponse { Description = "Bad Request" };
150+
break;
151+
152+
case OpType.Replace:
153+
// Request Edits
154+
operation.Value.AddConditionalHeader();
155+
156+
// Response Edits
157+
operation.Value.AddResponseWithContent("200", "OK", context.SchemaRepository.Schemas[entityType.Name]);
158+
operation.Value.Responses["400"] = new OpenApiResponse { Description = "Bad Request" };
159+
operation.Value.Responses["404"] = new OpenApiResponse { Description = "Not Found" };
160+
operation.Value.Responses["410"] = new OpenApiResponse { Description = "Gone" };
161+
operation.Value.AddConflictResponse(context.SchemaRepository.Schemas[entityType.Name]);
162+
break;
163+
}
164+
}
165+
}
166+
167+
/// <summary>
168+
/// Adds the relevant operation to the dictionary if it is present.
169+
/// </summary>
170+
/// <remarks>
171+
/// A developer can override the <see cref="TableController{TEntity}"/> actions to disable
172+
/// any operation (e.g. to create a read-only controller). So we need to check to ensure
173+
/// that every element is there.
174+
/// </remarks>
175+
/// <param name="operations">The operations dictionary to modify.</param>
176+
/// <param name="opType">The internal operation type (Create, Delete, Query, etc.)</param>
177+
/// <param name="document">The <see cref="OpenApiDocument"/> being processed.</param>
178+
/// <param name="path">The expected path for the operation type.</param>
179+
/// <param name="operationType">The operation type being processed.</param>
180+
private static void AddOperationIfPresent(Dictionary<OpType, OpenApiOperation> operations, OpType opType, OpenApiDocument document, string path, OperationType operationType)
181+
{
182+
if (document.Paths.TryGetValue(path, out OpenApiPathItem? pathValue))
183+
{
184+
if (pathValue!.Operations.TryGetValue(operationType, out OpenApiOperation? operation))
185+
{
186+
operations[opType] = operation!;
187+
}
188+
}
189+
}
190+
191+
/// <summary>
192+
/// Retrieves the entity type for a <see cref="TableController{TEntity}"/>.
193+
/// </summary>
194+
/// <param name="controllerType">The type for the controller.</param>
195+
/// <param name="entityType">The type for the entity, or <c>null</c> if the controller doesn't have an entity type.</param>
196+
/// <returns><c>true</c> if the table controller has an entity type.</returns>
197+
internal static bool TryGetTableEntityType(Type controllerType, out Type? entityType)
198+
{
199+
entityType = controllerType.BaseType?.GetGenericArguments().FirstOrDefault();
200+
return entityType != null;
201+
}
202+
203+
/// <summary>
204+
/// Retrieves the route path for a controller.
205+
/// </summary>
206+
/// <param name="context">The filter context.</param>
207+
/// <param name="controller">The controller to check.</param>
208+
/// <returns>The route path for the controller.</returns>
209+
internal static string? GetRoutePathFromContext(DocumentFilterContext context, Type controller)
210+
=> context.ApiDescriptions.FirstOrDefault(m => IsApiDescriptionForController(m, controller))?.RelativePath;
211+
212+
/// <summary>
213+
/// Determines if the controller type is represented by the API Description.
214+
/// </summary>
215+
/// <param name="description">The <see cref="ApiDescription"/> being handled.</param>
216+
/// <param name="controllerType">The type of the controller being used.</param>
217+
/// <returns><c>true</c> if the Api description represents the controller.</returns>
218+
internal static bool IsApiDescriptionForController(ApiDescription description, Type controllerType)
219+
=> description.TryGetMethodInfo(out MethodInfo methodInfo)
220+
&& methodInfo.ReflectedType == controllerType
221+
&& (methodInfo.Name.Equals(queryMethod) || methodInfo.Name.Equals(createMethod));
222+
223+
/// <summary>
224+
/// Returns a list of all table controllers in the provided assembly.
225+
/// </summary>
226+
/// <param name="assembly">The assembly to query. Be default, the calling assembly is queried.</param>
227+
/// <returns>The list of table controllers in the assembly.</returns>
228+
internal static List<Type> GetAllTableControllers(Assembly? assembly)
229+
=> (assembly ?? Assembly.GetCallingAssembly()).GetTypes().Where(IsTableController).ToList();
230+
231+
/// <summary>
232+
/// Determines if the controller type provided is a datasync table controller.
233+
/// </summary>
234+
/// <param name="type">The type of the table controller.</param>
235+
/// <returns><c>true</c> if the type is a datasync table controller.</returns>
236+
internal static bool IsTableController(Type type)
237+
{
238+
if (!type.IsAbstract && type.BaseType != null && type.BaseType.IsGenericType == true)
239+
{
240+
if (type.GetCustomAttribute<DatasyncControllerAttribute>() != null)
241+
{
242+
return true;
243+
}
244+
}
245+
246+
return false;
247+
}
248+
249+
/// <summary>
250+
/// A type representing a single page of entities.
251+
/// </summary>
252+
/// <typeparam name="T">The type of the entity.</typeparam>
253+
[ExcludeFromCodeCoverage(Justification = "Model class - coverage not needed")]
254+
internal class Page<T>
255+
{
256+
/// <summary>
257+
/// The list of entities in this page of the results.
258+
/// </summary>
259+
public IEnumerable<T> Items { get; } = [];
260+
261+
/// <summary>
262+
/// The count of all the entities in the result set.
263+
/// </summary>
264+
public long? Count { get; }
265+
266+
/// <summary>
267+
/// The URI to the next page of entities.
268+
/// </summary>
269+
public Uri? NextLink { get; }
270+
}
271+
}

0 commit comments

Comments
 (0)