Skip to content

Commit

Permalink
(#39) Added NSwag support for server-side OpenAPI (#42)
Browse files Browse the repository at this point in the history
* (#39) NSwag support
* (#39) Code coverage and corrections.
  • Loading branch information
adrianhall authored Jun 17, 2024
1 parent ba7d570 commit 4b0c692
Show file tree
Hide file tree
Showing 19 changed files with 2,100 additions and 8 deletions.
1 change: 1 addition & 0 deletions .github/workflows/SignedPackageFileList.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
**/CommunityToolkit.Datasync.Server.EntityFrameworkCore
**/CommunityToolkit.Datasync.Server.InMemory
**/CommunityToolkit.Datasync.Server.LiteDb
**/CommunityToolkit.Datasync.Server.NSwag
24 changes: 22 additions & 2 deletions Datasync.Toolkit.sln
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,19 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CommunityToolkit.Datasync.S
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CommunityToolkit.Datasync.TestService", "tests\CommunityToolkit.Datasync.TestService\CommunityToolkit.Datasync.TestService.csproj", "{1A3EE020-7299-4F00-A11F-06DD241EC6EA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Datasync.TestCommon", "tests\CommunityToolkit.Datasync.TestCommon\CommunityToolkit.Datasync.TestCommon.csproj", "{AC514FCF-C0D8-438F-A12C-A259CEB7B43D}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CommunityToolkit.Datasync.TestCommon", "tests\CommunityToolkit.Datasync.TestCommon\CommunityToolkit.Datasync.TestCommon.csproj", "{AC514FCF-C0D8-438F-A12C-A259CEB7B43D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Datasync.Common", "src\CommunityToolkit.Datasync.Common\CommunityToolkit.Datasync.Common.csproj", "{E1AC26CF-5C5C-400C-97F6-416D6FCF5BC7}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CommunityToolkit.Datasync.Common", "src\CommunityToolkit.Datasync.Common\CommunityToolkit.Datasync.Common.csproj", "{E1AC26CF-5C5C-400C-97F6-416D6FCF5BC7}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{2D2A6EFC-015D-4258-96D4-24C78F8C59F9}"
ProjectSection(SolutionItems) = preProject
.github\workflows\SignedPackageFileList.txt = .github\workflows\SignedPackageFileList.txt
.github\workflows\SignedTemplateFileList.txt = .github\workflows\SignedTemplateFileList.txt
EndProjectSection
EndProject
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}"
EndProject
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}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Expand Down Expand Up @@ -115,6 +125,14 @@ Global
{E1AC26CF-5C5C-400C-97F6-416D6FCF5BC7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E1AC26CF-5C5C-400C-97F6-416D6FCF5BC7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E1AC26CF-5C5C-400C-97F6-416D6FCF5BC7}.Release|Any CPU.Build.0 = Release|Any CPU
{C56A5630-03D7-43EF-BC09-69CA97152CFC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C56A5630-03D7-43EF-BC09-69CA97152CFC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C56A5630-03D7-43EF-BC09-69CA97152CFC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C56A5630-03D7-43EF-BC09-69CA97152CFC}.Release|Any CPU.Build.0 = Release|Any CPU
{983FB40E-BA00-4055-9A8A-24E1A351FB5B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{983FB40E-BA00-4055-9A8A-24E1A351FB5B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{983FB40E-BA00-4055-9A8A-24E1A351FB5B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{983FB40E-BA00-4055-9A8A-24E1A351FB5B}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -136,6 +154,8 @@ Global
{1A3EE020-7299-4F00-A11F-06DD241EC6EA} = {D59F1489-5D74-4F52-B78B-88037EAB2838}
{AC514FCF-C0D8-438F-A12C-A259CEB7B43D} = {D59F1489-5D74-4F52-B78B-88037EAB2838}
{E1AC26CF-5C5C-400C-97F6-416D6FCF5BC7} = {84AD662A-4B9E-4E64-834D-72529FB7FCE5}
{C56A5630-03D7-43EF-BC09-69CA97152CFC} = {84AD662A-4B9E-4E64-834D-72529FB7FCE5}
{983FB40E-BA00-4055-9A8A-24E1A351FB5B} = {D59F1489-5D74-4F52-B78B-88037EAB2838}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {78A935E9-8F14-448A-BEDF-360FB742F14E}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>Provides necessary capabilities for supporting the Datasync server library when using NSwag for creating OpenAPI definitions.</Description>
</PropertyGroup>

<Import Project="..\Shared.Build.props" />

<ItemGroup>
<InternalsVisibleTo Include="CommunityToolkit.Datasync.Server.NSwag.Test" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="NSwag.AspNetCore" Version="14.0.8" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\CommunityToolkit.Datasync.Server\CommunityToolkit.Datasync.Server.csproj" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using CommunityToolkit.Datasync.Server.Filters;
using NJsonSchema;
using NSwag;
using NSwag.Generation.Processors;
using NSwag.Generation.Processors.Contexts;
using System.Net;
using System.Reflection;

namespace CommunityToolkit.Datasync.Server.NSwag;

/// <summary>
/// Implements an <see cref="IOperationProcessor"/> for handling datasync table controllers.
/// </summary>
public class DatasyncOperationProcessor : IOperationProcessor
{
/// <summary>Processes the specified method information.</summary>
/// <param name="context">The processor context.</param>
/// <returns>true if the operation should be added to the Swagger specification.</returns>
public bool Process(OperationProcessorContext context)
{
if (IsTableController(context.ControllerType))
{
ProcessDatasyncOperation(context);
}

return true;
}

/// <summary>
/// Determines if the controller type provided is a datasync table controller.
/// </summary>
/// <param name="type">The type of the table controller.</param>
/// <returns><c>true</c> if the type is a datasync table controller.</returns>
internal static bool IsTableController(Type type)
{
if (!type.IsAbstract && type.BaseType != null && type.BaseType.IsGenericType == true)
{
if (type.GetCustomAttribute<DatasyncControllerAttribute>() != null)
{
return true;
}
}

return false;
}

/// <summary>
/// Returns the entity type being handled by the controller type.
/// </summary>
/// <param name="controllerType">The <see cref="Type"/> of the controller.</param>
/// <returns>The Type for the entity.</returns>
/// <exception cref="ArgumentException">If the controller type is not a generic type.</exception>
internal static Type GetTableEntityType(Type controllerType)
=> controllerType.BaseType?.GetGenericArguments().FirstOrDefault()
?? throw new ArgumentException("Unable to retrieve generic entity type");

private static void ProcessDatasyncOperation(OperationProcessorContext context)
{
OpenApiOperation operation = context.OperationDescription.Operation;
string method = context.OperationDescription.Method;
string path = context.OperationDescription.Path;
Type entityType = GetTableEntityType(context.ControllerType);
JsonSchema entitySchemaRef = GetEntityReference(context, entityType);

if (method.Equals("DELETE", StringComparison.InvariantCultureIgnoreCase))
{
operation.AddConditionalRequestSupport(entitySchemaRef);
operation.SetResponse(HttpStatusCode.NoContent);
operation.SetResponse(HttpStatusCode.NotFound);
operation.SetResponse(HttpStatusCode.Gone);
}

if (method.Equals("GET", StringComparison.InvariantCultureIgnoreCase) && path.EndsWith("/{id}"))
{
operation.AddConditionalRequestSupport(entitySchemaRef, true);
operation.SetResponse(HttpStatusCode.OK, entitySchemaRef);
operation.SetResponse(HttpStatusCode.NotFound);
}

if (method.Equals("GET", StringComparison.InvariantCultureIgnoreCase) && !path.EndsWith("/{id}"))
{
operation.AddODataQueryParameters();
operation.SetResponse(HttpStatusCode.OK, CreateListSchema(entitySchemaRef, entityType.Name), false);
operation.SetResponse(HttpStatusCode.BadRequest);
}

if (method.Equals("POST", StringComparison.InvariantCultureIgnoreCase))
{
operation.AddConditionalRequestSupport(entitySchemaRef, true);
operation.SetResponse(HttpStatusCode.Created, entitySchemaRef);
operation.SetResponse(HttpStatusCode.BadRequest);
}

if (method.Equals("PUT", StringComparison.InvariantCultureIgnoreCase))
{
operation.AddConditionalRequestSupport(entitySchemaRef);
operation.SetResponse(HttpStatusCode.OK, entitySchemaRef);
operation.SetResponse(HttpStatusCode.BadRequest);
operation.SetResponse(HttpStatusCode.NotFound);
operation.SetResponse(HttpStatusCode.Gone);
}
}

/// <summary>
/// Either reads or generates the required entity type schema.
/// </summary>
/// <param name="context">The context for the operation processor.</param>
/// <param name="entityType">The entity type needed.</param>
/// <returns>A reference to the entity schema.</returns>
private static JsonSchema GetEntityReference(OperationProcessorContext context, Type entityType)
{
string schemaName = context.SchemaGenerator.Settings.SchemaNameGenerator.Generate(entityType);
if (!context.Document.Definitions.TryGetValue(schemaName, out JsonSchema? value))
{
JsonSchema newSchema = context.SchemaGenerator.Generate(entityType);
value = newSchema;
context.Document.Definitions.Add(schemaName, value);
}

JsonSchema actualSchema = value;
return new JsonSchema { Reference = actualSchema };
}

/// <summary>
/// Creates the paged item schema reference.
/// </summary>
/// <param name="entitySchema">The entity schema reference.</param>
/// <param name="entityName">The name of the entity handled by the list operation.</param>
/// <returns>The list schema reference</returns>
private static JsonSchema CreateListSchema(JsonSchema entitySchema, string entityName)
{
JsonSchema listSchemaRef = new()
{
Description = $"A page of {entityName} entities",
Type = JsonObjectType.Object
};
listSchemaRef.Properties["items"] = new JsonSchemaProperty
{
Description = "The entities in this page of results",
Type = JsonObjectType.Array,
Item = entitySchema,
IsReadOnly = true,
IsNullableRaw = true
};
listSchemaRef.Properties["count"] = new JsonSchemaProperty
{
Description = "The count of all entities in the result set",
Type = JsonObjectType.Integer,
IsReadOnly = true,
IsNullableRaw = true
};
listSchemaRef.Properties["nextLink"] = new JsonSchemaProperty
{
Description = "The URI to the next page of entities",
Type = JsonObjectType.String,
Format = "uri",
IsReadOnly = true,
IsNullableRaw = true
};
return listSchemaRef;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using NJsonSchema;
using NJsonSchema.Generation;

namespace CommunityToolkit.Datasync.Server.NSwag;

/// <summary>
/// NSwag Schema processor for the Community Datasync Toolkit.
/// </summary>
public class DatasyncSchemaProcessor : ISchemaProcessor
{
/// <summary>
/// List of the system properties within the <see cref="ITableData"/> interface.
/// </summary>
private static readonly string[] systemProperties = ["deleted", "updatedAt", "version"];

/// <summary>
/// Processes each schema in turn, doing required modifications.
/// </summary>
/// <param name="context">The schema processor context.</param>
public void Process(SchemaProcessorContext context)
{
if (context.ContextualType.Type.GetInterfaces().Contains(typeof(ITableData)))
{
foreach (KeyValuePair<string, JsonSchemaProperty> prop in context.Schema.Properties)
{
if (systemProperties.Contains(prop.Key))
{
prop.Value.IsReadOnly = true;
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// This file is used by Code Analysis to maintain SuppressMessage
// attributes that are applied to this project.
// Project-level suppressions either have no target or are given
// a specific target and scoped to a namespace, type, member, etc.

using System.Diagnostics.CodeAnalysis;

[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)")]
Loading

0 comments on commit 4b0c692

Please sign in to comment.