Skip to content

Commit 4b0c692

Browse files
authored
(#39) Added NSwag support for server-side OpenAPI (#42)
* (#39) NSwag support * (#39) Code coverage and corrections.
1 parent ba7d570 commit 4b0c692

File tree

19 files changed

+2100
-8
lines changed

19 files changed

+2100
-8
lines changed

.github/workflows/SignedPackageFileList.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@
55
**/CommunityToolkit.Datasync.Server.EntityFrameworkCore
66
**/CommunityToolkit.Datasync.Server.InMemory
77
**/CommunityToolkit.Datasync.Server.LiteDb
8+
**/CommunityToolkit.Datasync.Server.NSwag

Datasync.Toolkit.sln

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,19 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CommunityToolkit.Datasync.S
4141
EndProject
4242
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CommunityToolkit.Datasync.TestService", "tests\CommunityToolkit.Datasync.TestService\CommunityToolkit.Datasync.TestService.csproj", "{1A3EE020-7299-4F00-A11F-06DD241EC6EA}"
4343
EndProject
44-
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Datasync.TestCommon", "tests\CommunityToolkit.Datasync.TestCommon\CommunityToolkit.Datasync.TestCommon.csproj", "{AC514FCF-C0D8-438F-A12C-A259CEB7B43D}"
44+
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CommunityToolkit.Datasync.TestCommon", "tests\CommunityToolkit.Datasync.TestCommon\CommunityToolkit.Datasync.TestCommon.csproj", "{AC514FCF-C0D8-438F-A12C-A259CEB7B43D}"
4545
EndProject
46-
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Datasync.Common", "src\CommunityToolkit.Datasync.Common\CommunityToolkit.Datasync.Common.csproj", "{E1AC26CF-5C5C-400C-97F6-416D6FCF5BC7}"
46+
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CommunityToolkit.Datasync.Common", "src\CommunityToolkit.Datasync.Common\CommunityToolkit.Datasync.Common.csproj", "{E1AC26CF-5C5C-400C-97F6-416D6FCF5BC7}"
47+
EndProject
48+
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{2D2A6EFC-015D-4258-96D4-24C78F8C59F9}"
49+
ProjectSection(SolutionItems) = preProject
50+
.github\workflows\SignedPackageFileList.txt = .github\workflows\SignedPackageFileList.txt
51+
.github\workflows\SignedTemplateFileList.txt = .github\workflows\SignedTemplateFileList.txt
52+
EndProjectSection
53+
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}"
55+
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}"
4757
EndProject
4858
Global
4959
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -115,6 +125,14 @@ Global
115125
{E1AC26CF-5C5C-400C-97F6-416D6FCF5BC7}.Debug|Any CPU.Build.0 = Debug|Any CPU
116126
{E1AC26CF-5C5C-400C-97F6-416D6FCF5BC7}.Release|Any CPU.ActiveCfg = Release|Any CPU
117127
{E1AC26CF-5C5C-400C-97F6-416D6FCF5BC7}.Release|Any CPU.Build.0 = Release|Any CPU
128+
{C56A5630-03D7-43EF-BC09-69CA97152CFC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
129+
{C56A5630-03D7-43EF-BC09-69CA97152CFC}.Debug|Any CPU.Build.0 = Debug|Any CPU
130+
{C56A5630-03D7-43EF-BC09-69CA97152CFC}.Release|Any CPU.ActiveCfg = Release|Any CPU
131+
{C56A5630-03D7-43EF-BC09-69CA97152CFC}.Release|Any CPU.Build.0 = Release|Any CPU
132+
{983FB40E-BA00-4055-9A8A-24E1A351FB5B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
133+
{983FB40E-BA00-4055-9A8A-24E1A351FB5B}.Debug|Any CPU.Build.0 = Debug|Any CPU
134+
{983FB40E-BA00-4055-9A8A-24E1A351FB5B}.Release|Any CPU.ActiveCfg = Release|Any CPU
135+
{983FB40E-BA00-4055-9A8A-24E1A351FB5B}.Release|Any CPU.Build.0 = Release|Any CPU
118136
EndGlobalSection
119137
GlobalSection(SolutionProperties) = preSolution
120138
HideSolutionNode = FALSE
@@ -136,6 +154,8 @@ Global
136154
{1A3EE020-7299-4F00-A11F-06DD241EC6EA} = {D59F1489-5D74-4F52-B78B-88037EAB2838}
137155
{AC514FCF-C0D8-438F-A12C-A259CEB7B43D} = {D59F1489-5D74-4F52-B78B-88037EAB2838}
138156
{E1AC26CF-5C5C-400C-97F6-416D6FCF5BC7} = {84AD662A-4B9E-4E64-834D-72529FB7FCE5}
157+
{C56A5630-03D7-43EF-BC09-69CA97152CFC} = {84AD662A-4B9E-4E64-834D-72529FB7FCE5}
158+
{983FB40E-BA00-4055-9A8A-24E1A351FB5B} = {D59F1489-5D74-4F52-B78B-88037EAB2838}
139159
EndGlobalSection
140160
GlobalSection(ExtensibilityGlobals) = postSolution
141161
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 NSwag for creating OpenAPI definitions.</Description>
4+
</PropertyGroup>
5+
6+
<Import Project="..\Shared.Build.props" />
7+
8+
<ItemGroup>
9+
<InternalsVisibleTo Include="CommunityToolkit.Datasync.Server.NSwag.Test" />
10+
</ItemGroup>
11+
12+
<ItemGroup>
13+
<PackageReference Include="NSwag.AspNetCore" Version="14.0.8" />
14+
</ItemGroup>
15+
16+
<ItemGroup>
17+
<ProjectReference Include="..\CommunityToolkit.Datasync.Server\CommunityToolkit.Datasync.Server.csproj" />
18+
</ItemGroup>
19+
</Project>
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
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 NJsonSchema;
7+
using NSwag;
8+
using NSwag.Generation.Processors;
9+
using NSwag.Generation.Processors.Contexts;
10+
using System.Net;
11+
using System.Reflection;
12+
13+
namespace CommunityToolkit.Datasync.Server.NSwag;
14+
15+
/// <summary>
16+
/// Implements an <see cref="IOperationProcessor"/> for handling datasync table controllers.
17+
/// </summary>
18+
public class DatasyncOperationProcessor : IOperationProcessor
19+
{
20+
/// <summary>Processes the specified method information.</summary>
21+
/// <param name="context">The processor context.</param>
22+
/// <returns>true if the operation should be added to the Swagger specification.</returns>
23+
public bool Process(OperationProcessorContext context)
24+
{
25+
if (IsTableController(context.ControllerType))
26+
{
27+
ProcessDatasyncOperation(context);
28+
}
29+
30+
return true;
31+
}
32+
33+
/// <summary>
34+
/// Determines if the controller type provided is a datasync table controller.
35+
/// </summary>
36+
/// <param name="type">The type of the table controller.</param>
37+
/// <returns><c>true</c> if the type is a datasync table controller.</returns>
38+
internal static bool IsTableController(Type type)
39+
{
40+
if (!type.IsAbstract && type.BaseType != null && type.BaseType.IsGenericType == true)
41+
{
42+
if (type.GetCustomAttribute<DatasyncControllerAttribute>() != null)
43+
{
44+
return true;
45+
}
46+
}
47+
48+
return false;
49+
}
50+
51+
/// <summary>
52+
/// Returns the entity type being handled by the controller type.
53+
/// </summary>
54+
/// <param name="controllerType">The <see cref="Type"/> of the controller.</param>
55+
/// <returns>The Type for the entity.</returns>
56+
/// <exception cref="ArgumentException">If the controller type is not a generic type.</exception>
57+
internal static Type GetTableEntityType(Type controllerType)
58+
=> controllerType.BaseType?.GetGenericArguments().FirstOrDefault()
59+
?? throw new ArgumentException("Unable to retrieve generic entity type");
60+
61+
private static void ProcessDatasyncOperation(OperationProcessorContext context)
62+
{
63+
OpenApiOperation operation = context.OperationDescription.Operation;
64+
string method = context.OperationDescription.Method;
65+
string path = context.OperationDescription.Path;
66+
Type entityType = GetTableEntityType(context.ControllerType);
67+
JsonSchema entitySchemaRef = GetEntityReference(context, entityType);
68+
69+
if (method.Equals("DELETE", StringComparison.InvariantCultureIgnoreCase))
70+
{
71+
operation.AddConditionalRequestSupport(entitySchemaRef);
72+
operation.SetResponse(HttpStatusCode.NoContent);
73+
operation.SetResponse(HttpStatusCode.NotFound);
74+
operation.SetResponse(HttpStatusCode.Gone);
75+
}
76+
77+
if (method.Equals("GET", StringComparison.InvariantCultureIgnoreCase) && path.EndsWith("/{id}"))
78+
{
79+
operation.AddConditionalRequestSupport(entitySchemaRef, true);
80+
operation.SetResponse(HttpStatusCode.OK, entitySchemaRef);
81+
operation.SetResponse(HttpStatusCode.NotFound);
82+
}
83+
84+
if (method.Equals("GET", StringComparison.InvariantCultureIgnoreCase) && !path.EndsWith("/{id}"))
85+
{
86+
operation.AddODataQueryParameters();
87+
operation.SetResponse(HttpStatusCode.OK, CreateListSchema(entitySchemaRef, entityType.Name), false);
88+
operation.SetResponse(HttpStatusCode.BadRequest);
89+
}
90+
91+
if (method.Equals("POST", StringComparison.InvariantCultureIgnoreCase))
92+
{
93+
operation.AddConditionalRequestSupport(entitySchemaRef, true);
94+
operation.SetResponse(HttpStatusCode.Created, entitySchemaRef);
95+
operation.SetResponse(HttpStatusCode.BadRequest);
96+
}
97+
98+
if (method.Equals("PUT", StringComparison.InvariantCultureIgnoreCase))
99+
{
100+
operation.AddConditionalRequestSupport(entitySchemaRef);
101+
operation.SetResponse(HttpStatusCode.OK, entitySchemaRef);
102+
operation.SetResponse(HttpStatusCode.BadRequest);
103+
operation.SetResponse(HttpStatusCode.NotFound);
104+
operation.SetResponse(HttpStatusCode.Gone);
105+
}
106+
}
107+
108+
/// <summary>
109+
/// Either reads or generates the required entity type schema.
110+
/// </summary>
111+
/// <param name="context">The context for the operation processor.</param>
112+
/// <param name="entityType">The entity type needed.</param>
113+
/// <returns>A reference to the entity schema.</returns>
114+
private static JsonSchema GetEntityReference(OperationProcessorContext context, Type entityType)
115+
{
116+
string schemaName = context.SchemaGenerator.Settings.SchemaNameGenerator.Generate(entityType);
117+
if (!context.Document.Definitions.TryGetValue(schemaName, out JsonSchema? value))
118+
{
119+
JsonSchema newSchema = context.SchemaGenerator.Generate(entityType);
120+
value = newSchema;
121+
context.Document.Definitions.Add(schemaName, value);
122+
}
123+
124+
JsonSchema actualSchema = value;
125+
return new JsonSchema { Reference = actualSchema };
126+
}
127+
128+
/// <summary>
129+
/// Creates the paged item schema reference.
130+
/// </summary>
131+
/// <param name="entitySchema">The entity schema reference.</param>
132+
/// <param name="entityName">The name of the entity handled by the list operation.</param>
133+
/// <returns>The list schema reference</returns>
134+
private static JsonSchema CreateListSchema(JsonSchema entitySchema, string entityName)
135+
{
136+
JsonSchema listSchemaRef = new()
137+
{
138+
Description = $"A page of {entityName} entities",
139+
Type = JsonObjectType.Object
140+
};
141+
listSchemaRef.Properties["items"] = new JsonSchemaProperty
142+
{
143+
Description = "The entities in this page of results",
144+
Type = JsonObjectType.Array,
145+
Item = entitySchema,
146+
IsReadOnly = true,
147+
IsNullableRaw = true
148+
};
149+
listSchemaRef.Properties["count"] = new JsonSchemaProperty
150+
{
151+
Description = "The count of all entities in the result set",
152+
Type = JsonObjectType.Integer,
153+
IsReadOnly = true,
154+
IsNullableRaw = true
155+
};
156+
listSchemaRef.Properties["nextLink"] = new JsonSchemaProperty
157+
{
158+
Description = "The URI to the next page of entities",
159+
Type = JsonObjectType.String,
160+
Format = "uri",
161+
IsReadOnly = true,
162+
IsNullableRaw = true
163+
};
164+
return listSchemaRef;
165+
}
166+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
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 NJsonSchema;
6+
using NJsonSchema.Generation;
7+
8+
namespace CommunityToolkit.Datasync.Server.NSwag;
9+
10+
/// <summary>
11+
/// NSwag Schema processor for the Community Datasync Toolkit.
12+
/// </summary>
13+
public class DatasyncSchemaProcessor : ISchemaProcessor
14+
{
15+
/// <summary>
16+
/// List of the system properties within the <see cref="ITableData"/> interface.
17+
/// </summary>
18+
private static readonly string[] systemProperties = ["deleted", "updatedAt", "version"];
19+
20+
/// <summary>
21+
/// Processes each schema in turn, doing required modifications.
22+
/// </summary>
23+
/// <param name="context">The schema processor context.</param>
24+
public void Process(SchemaProcessorContext context)
25+
{
26+
if (context.ContextualType.Type.GetInterfaces().Contains(typeof(ITableData)))
27+
{
28+
foreach (KeyValuePair<string, JsonSchemaProperty> prop in context.Schema.Properties)
29+
{
30+
if (systemProperties.Contains(prop.Key))
31+
{
32+
prop.Value.IsReadOnly = true;
33+
}
34+
}
35+
}
36+
}
37+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// This file is used by Code Analysis to maintain SuppressMessage
2+
// attributes that are applied to this project.
3+
// Project-level suppressions either have no target or are given
4+
// a specific target and scoped to a namespace, type, member, etc.
5+
6+
using System.Diagnostics.CodeAnalysis;
7+
8+
[assembly: SuppressMessage("Performance", "SYSLIB1045:Convert to 'GeneratedRegexAttribute'.", Justification = "<Pending>", Scope = "member", Target = "~M:CommunityToolkit.Datasync.Server.NSwag.OpenApiDatasyncExtensions.SetResponse(NSwag.OpenApiOperation,System.Net.HttpStatusCode,NJsonSchema.JsonSchema,System.Boolean)")]

0 commit comments

Comments
 (0)