diff --git a/Datasync.Toolkit.sln b/Datasync.Toolkit.sln index c8232065..30283eb6 100644 --- a/Datasync.Toolkit.sln +++ b/Datasync.Toolkit.sln @@ -68,6 +68,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Datasync.S EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Datasync.Server.MongoDB.Test", "tests\CommunityToolkit.Datasync.Server.MongoDB.Test\CommunityToolkit.Datasync.Server.MongoDB.Test.csproj", "{4FC45D20-0BA9-484B-9040-641687659AF6}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Datasync.Server.OpenApi.Test", "tests\CommunityToolkit.Datasync.Server.OpenApi.Test\CommunityToolkit.Datasync.Server.OpenApi.Test.csproj", "{99E727A3-8EB3-437E-AAC8-3596E8B9B2AE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Datasync.Server.OpenApi", "src\CommunityToolkit.Datasync.Server.OpenApi\CommunityToolkit.Datasync.Server.OpenApi.csproj", "{505421EC-2598-4114-B2EC-997959B89E74}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -166,6 +170,14 @@ Global {4FC45D20-0BA9-484B-9040-641687659AF6}.Debug|Any CPU.Build.0 = Debug|Any CPU {4FC45D20-0BA9-484B-9040-641687659AF6}.Release|Any CPU.ActiveCfg = Release|Any CPU {4FC45D20-0BA9-484B-9040-641687659AF6}.Release|Any CPU.Build.0 = Release|Any CPU + {99E727A3-8EB3-437E-AAC8-3596E8B9B2AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {99E727A3-8EB3-437E-AAC8-3596E8B9B2AE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {99E727A3-8EB3-437E-AAC8-3596E8B9B2AE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {99E727A3-8EB3-437E-AAC8-3596E8B9B2AE}.Release|Any CPU.Build.0 = Release|Any CPU + {505421EC-2598-4114-B2EC-997959B89E74}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {505421EC-2598-4114-B2EC-997959B89E74}.Debug|Any CPU.Build.0 = Debug|Any CPU + {505421EC-2598-4114-B2EC-997959B89E74}.Release|Any CPU.ActiveCfg = Release|Any CPU + {505421EC-2598-4114-B2EC-997959B89E74}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -194,6 +206,8 @@ Global {A9967817-2A2C-4C6D-A133-967A6062E9B3} = {75F709FD-8CC2-4558-A802-FE57086167C2} {DC20ACF9-12E9-41D9-B672-CB5FD85548E9} = {84AD662A-4B9E-4E64-834D-72529FB7FCE5} {4FC45D20-0BA9-484B-9040-641687659AF6} = {D59F1489-5D74-4F52-B78B-88037EAB2838} + {99E727A3-8EB3-437E-AAC8-3596E8B9B2AE} = {D59F1489-5D74-4F52-B78B-88037EAB2838} + {505421EC-2598-4114-B2EC-997959B89E74} = {84AD662A-4B9E-4E64-834D-72529FB7FCE5} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {78A935E9-8F14-448A-BEDF-360FB742F14E} diff --git a/Directory.Packages.props b/Directory.Packages.props index f7599862..f9129f21 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -11,6 +11,7 @@ + diff --git a/samples/datasync-server/src/Sample.Datasync.Server/Program.cs b/samples/datasync-server/src/Sample.Datasync.Server/Program.cs index 71e5590f..e7e9af89 100644 --- a/samples/datasync-server/src/Sample.Datasync.Server/Program.cs +++ b/samples/datasync-server/src/Sample.Datasync.Server/Program.cs @@ -16,6 +16,7 @@ string? swaggerDriver = builder.Configuration["Swagger:Driver"]; bool nswagEnabled = swaggerDriver?.Equals("NSwag", StringComparison.InvariantCultureIgnoreCase) == true; bool swashbuckleEnabled = swaggerDriver?.Equals("Swashbuckle", StringComparison.InvariantCultureIgnoreCase) == true; +bool openApiEnabled = swaggerDriver?.Equals("NET9", StringComparison.InvariantCultureIgnoreCase) == true; builder.Services.AddDbContext(options => options.UseSqlServer(connectionString)); builder.Services.AddDatasyncServices(); @@ -23,15 +24,18 @@ if (nswagEnabled) { - _ = builder.Services - .AddOpenApiDocument(options => options.AddDatasyncProcessor()); + _ = builder.Services.AddOpenApiDocument(options => options.AddDatasyncProcessor()); } if (swashbuckleEnabled) { - _ = builder.Services - .AddEndpointsApiExplorer() - .AddSwaggerGen(options => options.AddDatasyncControllers()); + _ = builder.Services.AddEndpointsApiExplorer(); + _ = builder.Services.AddSwaggerGen(options => options.AddDatasyncControllers()); +} + +if (openApiEnabled) +{ + _ = builder.Services.AddOpenApi(); } WebApplication app = builder.Build(); @@ -58,4 +62,9 @@ app.UseAuthorization(); app.MapControllers(); +if (openApiEnabled) +{ + _ = app.MapOpenApi(pattern: "swagger/{documentName}/swagger.json"); +} + app.Run(); diff --git a/samples/datasync-server/src/Sample.Datasync.Server/Properties/launchSettings.json b/samples/datasync-server/src/Sample.Datasync.Server/Properties/launchSettings.json index d11ca618..1be36e07 100644 --- a/samples/datasync-server/src/Sample.Datasync.Server/Properties/launchSettings.json +++ b/samples/datasync-server/src/Sample.Datasync.Server/Properties/launchSettings.json @@ -1,29 +1,12 @@ { "$schema": "http://json.schemastore.org/launchsettings.json", - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:48768", - "sslPort": 44300 - } - }, "profiles": { "https": { "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "launchUrl": "https://localhost:5001/tables/todoitem", - "applicationUrl": "https://localhost:5001;http://localhost:5000", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "sqlDebugging": true - }, - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "launchUrl": "https://localhost:44300/tables/todoitem", + "launchUrl": "tables/todoitem", + "applicationUrl": "https://localhost:5001", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }, diff --git a/samples/datasync-server/src/Sample.Datasync.Server/Sample.Datasync.Server.csproj b/samples/datasync-server/src/Sample.Datasync.Server/Sample.Datasync.Server.csproj index 17892697..8bbaecc0 100644 --- a/samples/datasync-server/src/Sample.Datasync.Server/Sample.Datasync.Server.csproj +++ b/samples/datasync-server/src/Sample.Datasync.Server/Sample.Datasync.Server.csproj @@ -1,21 +1,26 @@ - net8.0 + net9.0 enable enable 2fc55b72-4090-46ad-ae44-8b6a415339b8 - - - - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/samples/datasync-server/src/Sample.Datasync.Server/appsettings.Development.json b/samples/datasync-server/src/Sample.Datasync.Server/appsettings.Development.json index 3d559e65..bc85dd30 100644 --- a/samples/datasync-server/src/Sample.Datasync.Server/appsettings.Development.json +++ b/samples/datasync-server/src/Sample.Datasync.Server/appsettings.Development.json @@ -6,6 +6,6 @@ } }, "Swagger": { - "Driver": "Swashbuckle" + "Driver": "net9" } } diff --git a/src/CommunityToolkit.Datasync.Server.OpenApi/CommunityToolkit.Datasync.Server.OpenApi.csproj b/src/CommunityToolkit.Datasync.Server.OpenApi/CommunityToolkit.Datasync.Server.OpenApi.csproj new file mode 100644 index 00000000..0ebe0db1 --- /dev/null +++ b/src/CommunityToolkit.Datasync.Server.OpenApi/CommunityToolkit.Datasync.Server.OpenApi.csproj @@ -0,0 +1,17 @@ + + + Provides necessary capabilities for supporting the Datasync server library when using the official OpenApi generator. + + + + + + + + + + + + + + diff --git a/src/CommunityToolkit.Datasync.Server.OpenApi/DatasyncOperationTransformer.cs b/src/CommunityToolkit.Datasync.Server.OpenApi/DatasyncOperationTransformer.cs new file mode 100644 index 00000000..f6d5ad66 --- /dev/null +++ b/src/CommunityToolkit.Datasync.Server.OpenApi/DatasyncOperationTransformer.cs @@ -0,0 +1,227 @@ +// 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 Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi.Models; + +namespace CommunityToolkit.Datasync.Server.OpenApi; + +/// +/// The document transformer for the Datasync services. +/// +public class DatasyncOperationTransformer : IOpenApiOperationTransformer +{ + /// + public async Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) + { + // Determine if this operation is in scope. + if (!IsDatasyncController(context)) + { + return; + } + + // Ensure the operation is set up for modification. + operation.Parameters ??= []; + + string? actionName = context.Description.ActionDescriptor.RouteValues["action"]; + if (actionName?.StartsWith("Create", StringComparison.InvariantCultureIgnoreCase) == true) + { + await TransformCreateAsync(operation, context, cancellationToken).ConfigureAwait(false); + return; + } + + if (actionName?.StartsWith("Delete", StringComparison.InvariantCultureIgnoreCase) == true) + { + await TransformDeleteAsync(operation, context, cancellationToken).ConfigureAwait(false); + return; + } + + if (actionName?.StartsWith("Query", StringComparison.InvariantCultureIgnoreCase) == true) + { + await TransformQueryAsync(operation, context, cancellationToken).ConfigureAwait(false); + return; + } + + if (actionName?.StartsWith("Read", StringComparison.InvariantCultureIgnoreCase) == true) + { + await TransformReadAsync(operation, context, cancellationToken).ConfigureAwait(false); + return; + } + + if (actionName?.StartsWith("Replace", StringComparison.InvariantCultureIgnoreCase) == true) + { + await TransformReplaceAsync(operation, context, cancellationToken).ConfigureAwait(false); + return; + } + + return; + } + + /// + /// Determines if a controller presented is a datasync controller. + /// + /// The transformer context. + /// true if the controller is a datasync controller; false otherwise. + internal static bool IsDatasyncController(OpenApiOperationTransformerContext context) + => context.Description.ActionDescriptor.FilterDescriptors.Any(fd => fd.Filter is DatasyncControllerAttribute); + + /// + /// Retrieves the entity type for the controller. + /// + /// The transformer context. + /// The type of the entity being served. + internal static Type GetEntityType(OpenApiOperationTransformerContext context) + { + Type? baseType = (context.Description.ActionDescriptor as ControllerActionDescriptor)?.ControllerTypeInfo.AsType(); + while (baseType is not null) + { + if (baseType.IsGenericType && baseType.GetGenericTypeDefinition() == typeof(TableController<>)) + { + Type? entityType = baseType.GetGenericArguments().FirstOrDefault(); + if (entityType is not null && typeof(ITableData).IsAssignableFrom(entityType)) + { + return entityType; + } + + throw new InvalidOperationException("Expecting the entity type to implement ITableData."); + } + + baseType = baseType.BaseType; + } + + throw new InvalidOperationException("Expecting the controller to be derived from TableController."); + } + + /// + /// Transforms a create operation. + /// + /// The operation to transform. + /// The operation transformer context. + /// A cancellation token to observe. + /// A task that resolves when the operation is complete. + internal Task TransformCreateAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) + { + Type entityType = GetEntityType(context); + + operation.AddRequestBody(context.GetSchemaForType(entityType)); + + operation.Responses.AddEntityResponse(StatusCodes.Status201Created, + context.GetSchemaForType(entityType), includeConditionalHeaders: true); + operation.Responses.AddStatusCode(StatusCodes.Status400BadRequest); + operation.Responses.AddEntityResponse(StatusCodes.Status409Conflict, + context.GetSchemaForType(entityType), includeConditionalHeaders: true); + operation.Responses.AddEntityResponse(StatusCodes.Status412PreconditionFailed, + context.GetSchemaForType(entityType), includeConditionalHeaders: true); + + return Task.CompletedTask; + } + + /// + /// Transforms a delete operation. + /// + /// The operation to transform. + /// The operation transformer context. + /// A cancellation token to observe. + /// A task that resolves when the operation is complete. + internal Task TransformDeleteAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) + { + Type entityType = GetEntityType(context); + + operation.Parameters.AddIfMatchHeader(); + operation.Parameters.AddIfUnmodifiedSinceHeader(); + + operation.Responses.AddStatusCode(StatusCodes.Status400BadRequest); + operation.Responses.AddStatusCode(StatusCodes.Status404NotFound); + operation.Responses.AddStatusCode(StatusCodes.Status410Gone); + operation.Responses.AddEntityResponse(StatusCodes.Status409Conflict, + context.GetSchemaForType(entityType), includeConditionalHeaders: true); + operation.Responses.AddEntityResponse(StatusCodes.Status412PreconditionFailed, + context.GetSchemaForType(entityType), includeConditionalHeaders: true); + + return Task.CompletedTask; + } + + /// + /// Transforms a query operation. + /// + /// The operation to transform. + /// The operation transformer context. + /// A cancellation token to observe. + /// A task that resolves when the operation is complete. + internal Task TransformQueryAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) + { + Type entityType = GetEntityType(context); + Type pagedEntityType = typeof(PagedResult<>).MakeGenericType(entityType); + + operation.Parameters.AddBooleanQueryParameter("$count", "Whether to include the total count of items matching the query in the result"); + operation.Parameters.AddStringQueryParameter("$filter", "The filter to apply to the query"); + operation.Parameters.AddStringQueryParameter("$orderby", "The comma-separated list of ordering instructions to apply to the query"); + operation.Parameters.AddStringQueryParameter("$select", "The comma-separated list of fields to return in the results"); + operation.Parameters.AddIntQueryParameter("$skip", "The number of items to skip", 0); + operation.Parameters.AddIntQueryParameter("$top", "The number of items to return", 1); + operation.Parameters.AddIncludeDeletedQuery(); + + operation.Responses.AddEntityResponse(StatusCodes.Status200OK, + context.GetSchemaForType(pagedEntityType), includeConditionalHeaders: false); + operation.Responses.AddStatusCode(StatusCodes.Status400BadRequest); + + return Task.CompletedTask; + } + + /// + /// Transforms a read operation. + /// + /// The operation to transform. + /// The operation transformer context. + /// A cancellation token to observe. + /// A task that resolves when the operation is complete. + internal Task TransformReadAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) + { + Type entityType = GetEntityType(context); + + operation.Parameters.AddIncludeDeletedQuery(); + operation.Parameters.AddIfNoneMatchHeader(); + operation.Parameters.AddIfModifiedSinceHeader(); + + operation.Responses.AddEntityResponse(StatusCodes.Status200OK, + context.GetSchemaForType(entityType), includeConditionalHeaders: true); + operation.Responses.AddStatusCode(StatusCodes.Status304NotModified); + operation.Responses.AddStatusCode(StatusCodes.Status404NotFound); + operation.Responses.AddStatusCode(StatusCodes.Status410Gone); + + return Task.CompletedTask; + } + + /// + /// Transforms a replace operation. + /// + /// The operation to transform. + /// The operation transformer context. + /// A cancellation token to observe. + /// A task that resolves when the operation is complete. + internal Task TransformReplaceAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) + { + Type entityType = GetEntityType(context); + + operation.AddRequestBody(context.GetSchemaForType(entityType)); + operation.Parameters.AddIncludeDeletedQuery(); + operation.Parameters.AddIfMatchHeader(); + operation.Parameters.AddIfUnmodifiedSinceHeader(); + + operation.Responses.AddEntityResponse(StatusCodes.Status200OK, + context.GetSchemaForType(entityType), includeConditionalHeaders: true); + operation.Responses.AddStatusCode(StatusCodes.Status400BadRequest); + operation.Responses.AddStatusCode(StatusCodes.Status404NotFound); + operation.Responses.AddStatusCode(StatusCodes.Status410Gone); + operation.Responses.AddEntityResponse(StatusCodes.Status409Conflict, + context.GetSchemaForType(entityType), includeConditionalHeaders: true); + operation.Responses.AddEntityResponse(StatusCodes.Status412PreconditionFailed, + context.GetSchemaForType(entityType), includeConditionalHeaders: true); + + return Task.CompletedTask; + } +} diff --git a/src/CommunityToolkit.Datasync.Server.OpenApi/InternalExtensions.cs b/src/CommunityToolkit.Datasync.Server.OpenApi/InternalExtensions.cs new file mode 100644 index 00000000..82ed11b0 --- /dev/null +++ b/src/CommunityToolkit.Datasync.Server.OpenApi/InternalExtensions.cs @@ -0,0 +1,243 @@ +// 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 Microsoft.AspNetCore.OpenApi; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; +using System.Net.Mime; + +namespace CommunityToolkit.Datasync.Server.OpenApi; + +/// +/// A set of internal extensions for code readability. +/// +internal static class InternalExtensions +{ + /// + /// Adds a request body to the operation. + /// + /// The operation to modify. + /// The schema for the entity in the body. + internal static void AddRequestBody(this OpenApiOperation operation, OpenApiSchema bodySchema) + { + operation.RequestBody ??= new OpenApiRequestBody(); + operation.RequestBody.Content.Add(MediaTypeNames.Application.Json, new OpenApiMediaType + { + Schema = bodySchema + }); + operation.RequestBody.Description = "The entity to process."; + operation.RequestBody.Required = true; + } + + /// + /// Adds a boolean query parameter to the operation parameters. + /// + /// The parameters collection. + /// The parameter name. + /// The parameter description. + internal static void AddBooleanQueryParameter(this IList parameters, string paramName, string description) + { + parameters.Add(new OpenApiParameter + { + Name = paramName, + In = ParameterLocation.Query, + Description = description, + Required = false, + Schema = new OpenApiSchema + { + Type = "string", + Enum = [new OpenApiString("true"), new OpenApiString("false")] + } + }); + } + + /// + /// Adds a date-time header to the parameter list. + /// + /// The parameters collection. + /// The parameter name. + /// The parameter description. + internal static void AddDateTimeHeader(this IList parameters, string headerName, string description) + { + parameters.Add(new OpenApiParameter + { + Name = headerName, + In = ParameterLocation.Header, + Description = description, + Required = false, + Schema = new OpenApiSchema { Type = "string", Format = "date-time" } + }); + } + + /// + /// Adds an entity response to the collection of responses. + /// + /// The responses collection. + /// The status code for the response. + /// The schema of the entity. + /// If true, include the headers for conditional access. + internal static void AddEntityResponse(this OpenApiResponses responses, + int statusCode, OpenApiSchema schema, bool includeConditionalHeaders = true) + { + OpenApiResponse response = new() + { + Description = ReasonPhrases.GetReasonPhrase(statusCode), + Content = new Dictionary + { + ["application/json"] = new OpenApiMediaType + { + Schema = schema + } + }, + Headers = includeConditionalHeaders ? new Dictionary + { + ["ETag"] = new OpenApiHeader + { + Description = "The ETag value for the entity", + Schema = new OpenApiSchema { Type = "string" } + }, + ["Last-Modified"] = new OpenApiHeader + { + Description = "The last modified timestamp for the entity", + Schema = new OpenApiSchema { Type = "string", Format = "date-time" } + } + } : null + }; + responses.Upsert(statusCode.ToString(), response); + } + + /// + /// Adds the __includeDeleted query parameter to the operation. + /// + /// The parameters list to modify. + internal static void AddIncludeDeletedQuery(this IList parameters) + => parameters.AddBooleanQueryParameter("__includeDeleted", "Include deleted items in the response ('true' or 'false')."); + + /// + /// Adds the If-Modified-Since header to the operation. + /// + /// The parameters list to modify. + internal static void AddIfModifiedSinceHeader(this IList parameters) + => parameters.AddDateTimeHeader("If-Modified-Since", "Timestamp to conditionally fetch the entity"); + + /// + /// Adds the If-Modified-Since header to the operation. + /// + /// The parameters list to modify. + internal static void AddIfUnmodifiedSinceHeader(this IList parameters) + => parameters.AddDateTimeHeader("If-Unmodified-Since", "Timestamp to conditionally fetch the entity"); + + /// + /// Adds the If-None-Match header to the operation. + /// + /// The parameters list to modify. + internal static void AddIfNoneMatchHeader(this IList parameters) + => parameters.AddStringHeader("If-None-Match", "ETag value to conditionally fetch the entity"); + + /// + /// Adds the If-Match header to the operation. + /// + /// The parameters list to modify. + internal static void AddIfMatchHeader(this IList parameters) + => parameters.AddStringHeader("If-Match", "ETag value to conditionally fetch the entity"); + + /// + /// Adds an int query parameter to the operation parameters. + /// + /// The parameters collection. + /// The parameter name. + /// The parameter description. + /// The minimum value for the parameter. + internal static void AddIntQueryParameter(this IList parameters, string paramName, string description, int minValue = 0) + { + parameters.Add(new OpenApiParameter + { + Name = paramName, + In = ParameterLocation.Query, + Description = description, + Required = false, + Schema = new OpenApiSchema { Type = "integer", Minimum = minValue } + }); + } + + /// + /// Adds a status code with no content to the responses collection. + /// + /// The responses collection. + /// The status code to add. + internal static void AddStatusCode(this OpenApiResponses responses, int statusCode) + => responses.Upsert(statusCode.ToString(), new OpenApiResponse { Description = ReasonPhrases.GetReasonPhrase(statusCode) }); + + /// + /// Adds a string header to the parameter list. + /// + /// The parameters collection. + /// The parameter name. + /// The parameter description. + internal static void AddStringHeader(this IList parameters, string headerName, string description) + { + parameters.Add(new OpenApiParameter + { + Name = headerName, + In = ParameterLocation.Header, + Description = description, + Required = false, + Schema = new OpenApiSchema { Type = "string" } + }); + } + + /// + /// Adds a string query parameter to the operation parameters. + /// + /// The parameters collection. + /// The parameter name. + /// The parameter description. + internal static void AddStringQueryParameter(this IList parameters, string paramName, string description) + { + parameters.Add(new OpenApiParameter + { + Name = paramName, + In = ParameterLocation.Query, + Description = description, + Required = false, + Schema = new OpenApiSchema { Type = "string" } + }); + } + + /// + /// Retrieves the schema for a given type. If the schema is not found, it is generated. If the schema + /// cannot be generated, an exception is thrown. + /// + /// The operation context. + /// The entity type. + /// The schema for the entity type. + /// Thrown if the schema cannot be found or generated. + internal static OpenApiSchema GetSchemaForType(this OpenApiOperationTransformerContext context, Type type) + { + // TODO: Add support for retrieving schemas. + return new OpenApiSchema { Type = "object" }; + } + + /// + /// Converts the property name to JSON casing. + /// + /// The JsonSerializerOptions object being used. + /// The name to convert. + /// The converted name. + internal static string ToJsonCasing(this IDatasyncServiceOptions options, string name) + => options.JsonSerializerOptions.PropertyNamingPolicy?.ConvertName(name) ?? name; + + /// + /// Upserts a response into the responses collection, overwriting the old version if it is there. + /// + /// The responses collection. + /// The key - generally the status code. + /// The response object. + internal static void Upsert(this OpenApiResponses responses, string key, OpenApiResponse response) + { + _ = responses.Remove(key); + responses.Add(key, response); + } +} diff --git a/src/CommunityToolkit.Datasync.Server.OpenApi/OpenApiExtensions.cs b/src/CommunityToolkit.Datasync.Server.OpenApi/OpenApiExtensions.cs new file mode 100644 index 00000000..1a5691e0 --- /dev/null +++ b/src/CommunityToolkit.Datasync.Server.OpenApi/OpenApiExtensions.cs @@ -0,0 +1,26 @@ +// 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 Microsoft.AspNetCore.OpenApi; + +namespace CommunityToolkit.Datasync.Server.OpenApi; + +/// +/// A set of extension methods to make it easier to configure the OpenAPI options for the Datasync services. +/// +public static class OpenApiExtensions +{ + /// + /// Adds the appropriate transformers to the OpenAPI options to support the Datasync services. + /// + /// The to modify. + /// The modified for chaining. + public static OpenApiOptions AddDatasyncTransformers(this OpenApiOptions options) + { + _ = options + .AddOperationTransformer(); + + return options; + } +} diff --git a/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Query.cs b/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Query.cs index f15368aa..5fc8dc1d 100644 --- a/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Query.cs +++ b/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Query.cs @@ -2,23 +2,21 @@ // 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.OData; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.OData.Extensions; +using Microsoft.AspNetCore.OData.Query; using Microsoft.AspNetCore.OData.Query.Expressions; using Microsoft.AspNetCore.OData.Query.Validator; -using Microsoft.AspNetCore.OData.Query.Wrapper; -using Microsoft.AspNetCore.OData.Query; using Microsoft.AspNetCore.OData.Routing.Controllers; +using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Microsoft.OData.UriParser; +using Microsoft.Extensions.Primitives; using Microsoft.OData; +using Microsoft.OData.UriParser; using System.Diagnostics.CodeAnalysis; -using CommunityToolkit.Datasync.Server.OData; -using Microsoft.AspNetCore.Http.Extensions; -using Microsoft.AspNetCore.WebUtilities; -using Microsoft.Extensions.Primitives; using System.Globalization; namespace CommunityToolkit.Datasync.Server; diff --git a/src/CommunityToolkit.Datasync.Server/Models/PagedResult.cs b/src/CommunityToolkit.Datasync.Server/Models/PagedResult.cs index 1929fc2f..27d28c6e 100644 --- a/src/CommunityToolkit.Datasync.Server/Models/PagedResult.cs +++ b/src/CommunityToolkit.Datasync.Server/Models/PagedResult.cs @@ -26,3 +26,29 @@ public class PagedResult(IEnumerable? items = null) /// public string? NextLink { get; set; } } + +/// +/// A paged result with a specific type. +/// +/// +/// This class is used in constructing OpenApi documents. +/// +/// The type of the entity. +public class PagedResult +{ + /// + /// The list of entities to include in the response. + /// + public required IEnumerable Items { get; set; } + + /// + /// The count of all the entities to be returned by the search (without paging). + /// + public int? Count { get; set; } + + /// + /// The arguments to retrieve the next page of items. The client needs to prepend + /// the URI of the table to this. + /// + public string? NextLink { get; set; } +} diff --git a/tests/CommunityToolkit.Datasync.Server.NSwag.Test/Service/Models.cs b/tests/CommunityToolkit.Datasync.Server.NSwag.Test/Service/Models.cs index fa90a103..512f22eb 100644 --- a/tests/CommunityToolkit.Datasync.Server.NSwag.Test/Service/Models.cs +++ b/tests/CommunityToolkit.Datasync.Server.NSwag.Test/Service/Models.cs @@ -4,7 +4,6 @@ using CommunityToolkit.Datasync.Server.EntityFrameworkCore; using CommunityToolkit.Datasync.TestCommon.Models; -using Microsoft.Spatial; namespace CommunityToolkit.Datasync.Server.NSwag.Test.Service; diff --git a/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/CommunityToolkit.Datasync.Server.OpenApi.Test.csproj b/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/CommunityToolkit.Datasync.Server.OpenApi.Test.csproj new file mode 100644 index 00000000..30d0eb2a --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/CommunityToolkit.Datasync.Server.OpenApi.Test.csproj @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + diff --git a/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/GlobalUsings.cs b/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/GlobalUsings.cs new file mode 100644 index 00000000..e67647b3 --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/GlobalUsings.cs @@ -0,0 +1,8 @@ +// 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. + +global using FluentAssertions; +global using System.Diagnostics.CodeAnalysis; +global using Xunit; + diff --git a/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/OpenApi_Tests.cs b/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/OpenApi_Tests.cs new file mode 100644 index 00000000..102150e2 --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/OpenApi_Tests.cs @@ -0,0 +1,47 @@ +// 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.OpenApi.Test.Service; +using CommunityToolkit.Datasync.TestCommon; +using Microsoft.AspNetCore.TestHost; +using System.Reflection; +using System.Text.RegularExpressions; + +namespace CommunityToolkit.Datasync.Server.OpenApi.Test; + +[ExcludeFromCodeCoverage] +public class OpenApi_Tests +{ + private readonly TestServer server = OpenApiServer.CreateTestServer(); + + [Fact] + public async Task GeneratesCorrectOpenApiFile() + { + HttpClient client = this.server.CreateClient(); + string actualContent = (await client.GetStringAsync("openapi/v1.json")).NormalizeContent(); + string expectedContent = Assembly.GetExecutingAssembly().ReadExternalFile("openapi.json"); + + // There is an x-generator field that is library specific and completely irrelevant + // to the comparison, so this line will remove it for comparison purposes. + Regex generatorRegex = new("\"x-generator\": \"[^\\\"]+\",[\r\n]+"); + actualContent = generatorRegex.Replace(actualContent, "", 1); + expectedContent = generatorRegex.Replace(expectedContent, "", 1); + + // If the expected content is different, it is really hard to diagnose why. + // Likelihood is that the OpenApi generator changed the formatting, and you + // just need to copy the new file into the swagger.json file location. However, + // do an actual diff and open up with a Swagger Editor before you just assume this + // is the case. + if (expectedContent != actualContent) + { + string outputPath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + await using StreamWriter output = new(Path.Combine(outputPath, "openapi.out.json")); + output.Write(actualContent); + output.Flush(); + output.Close(); + } + + actualContent.Should().Be(expectedContent); + } +} diff --git a/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/Service/Controllers.cs b/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/Service/Controllers.cs new file mode 100644 index 00000000..b87e4808 --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/Service/Controllers.cs @@ -0,0 +1,61 @@ +// 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.EntityFrameworkCore; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace CommunityToolkit.Datasync.Server.OpenApi.Test.Service; + +[ExcludeFromCodeCoverage] +[ApiExplorerSettings(IgnoreApi = false)] +public abstract class ReadonlyTableController : TableController where TData : class, ITableData +{ + [NonAction] + public override Task CreateAsync(CancellationToken cancellationToken = default) + => base.CreateAsync(cancellationToken); + + [NonAction] + public override Task DeleteAsync([FromRoute] string id, CancellationToken cancellationToken = default) + => base.DeleteAsync(id, cancellationToken); + + [NonAction] + public override Task ReplaceAsync([FromRoute] string id, CancellationToken cancellationToken = default) + => base.ReplaceAsync(id, cancellationToken); +} + +[Route("tables/kitchenreader")] +[ExcludeFromCodeCoverage] +[ApiExplorerSettings(IgnoreApi = false)] +public class KitchenReaderController : ReadonlyTableController +{ + public KitchenReaderController(ServiceDbContext context, ILogger logger) : base() + { + Repository = new EntityTableRepository(context); + Logger = logger; + } +} + +[Route("tables/kitchensink")] +[ExcludeFromCodeCoverage] +[ApiExplorerSettings(IgnoreApi = false)] +public class KitchenSinkController : TableController +{ + public KitchenSinkController(ServiceDbContext context, ILogger logger) : base() + { + Repository = new EntityTableRepository(context); + Logger = logger; + } +} + +[Route("tables/[controller]")] +[ExcludeFromCodeCoverage] +[ApiExplorerSettings(IgnoreApi = false)] +public class TodoItemController : TableController +{ + public TodoItemController(ServiceDbContext context) : base() + { + Repository = new EntityTableRepository(context); + } +} diff --git a/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/Service/Models.cs b/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/Service/Models.cs new file mode 100644 index 00000000..7db49597 --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/Service/Models.cs @@ -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 CommunityToolkit.Datasync.Server.EntityFrameworkCore; +using CommunityToolkit.Datasync.TestCommon.Models; + +namespace CommunityToolkit.Datasync.Server.OpenApi.Test.Service; + +[ExcludeFromCodeCoverage] +public class TodoItem : EntityTableData +{ + public string Title { get; set; } +} + +[ExcludeFromCodeCoverage] +public class KitchenSink : EntityTableData +{ + public bool BooleanValue { get; set; } + public byte ByteValue { get; set; } + public byte[] ByteArrayValue { get; set; } + public char CharValue { get; set; } + public DateOnly DateOnlyValue { get; set; } + public DateTime DateTimeValue { get; set; } + public DateTimeOffset DateTimeOffsetValue { get; set; } + public decimal DecimalValue { get; set; } + public double DoubleValue { get; set; } + public KitchenSinkState EnumValue { get; set; } + public float FloatValue { get; set; } + public Guid? GuidValue { get; set; } + public int IntValue { get; set; } + public long LongValue { get; set; } + public double? NullableDouble { get; set; } + public KitchenSinkState? NullableEnumValue { get; set; } + public string StringValue { get; set; } + public TimeOnly TimeOnlyValue { get; set; } +} diff --git a/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/Service/OpenApiServer.cs b/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/Service/OpenApiServer.cs new file mode 100644 index 00000000..80f56ffe --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/Service/OpenApiServer.cs @@ -0,0 +1,67 @@ +// 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 Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace CommunityToolkit.Datasync.Server.OpenApi.Test.Service; + +[ExcludeFromCodeCoverage] +internal static class OpenApiServer +{ + internal static TestServer CreateTestServer() + { + IWebHostBuilder builder = new WebHostBuilder() + .UseEnvironment("Test") + .UseContentRoot(AppContext.BaseDirectory) + .UseStartup(); + TestServer server = new(builder); + + using IServiceScope scope = server.Services.CreateScope(); + ServiceDbContext context = scope.ServiceProvider.GetRequiredService(); + context.InitializeDatabase(); + + return server; + } +} + +[ExcludeFromCodeCoverage] +internal class ServiceStartup +{ + public ServiceStartup(IConfiguration configuration) + { + Configuration = configuration; + DbConnection = new SqliteConnection("Data Source=:memory:"); + DbConnection.Open(); + } + + public IConfiguration Configuration { get; } + public SqliteConnection DbConnection { get; } + + public void ConfigureServices(IServiceCollection services) + { + services.AddDbContext( + options => options.UseSqlite(DbConnection).EnableDetailedErrors().EnableSensitiveDataLogging(), + contextLifetime: ServiceLifetime.Transient, + optionsLifetime: ServiceLifetime.Singleton); + services.AddDatasyncServices(); + services.AddControllers(); + services.AddOpenApi(options => options.AddDatasyncTransformers()); + } + + public static void Configure(IApplicationBuilder builder) + { + builder.UseRouting(); + builder.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + endpoints.MapOpenApi(); + }); + } +} diff --git a/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/Service/ServiceDbContext.cs b/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/Service/ServiceDbContext.cs new file mode 100644 index 00000000..1add7257 --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/Service/ServiceDbContext.cs @@ -0,0 +1,24 @@ +// 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.TestCommon.Databases; +using Microsoft.EntityFrameworkCore; + +namespace CommunityToolkit.Datasync.Server.OpenApi.Test.Service; + +[ExcludeFromCodeCoverage] +public class ServiceDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet KitchenSinks => Set(); + public DbSet TodoItems => Set(); + + public void InitializeDatabase() + { + bool created = Database.EnsureCreated(); + if (created && Database.IsSqlite()) + { + this.EnableSqliteExtensions(); + } + } +} diff --git a/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/openapi.json b/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/openapi.json new file mode 100644 index 00000000..612d14ca --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/openapi.json @@ -0,0 +1,1203 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "CommunityToolkit.Datasync.Server.OpenApi.Test | v1", + "version": "1.0.0" + }, + "paths": { + "/tables/kitchenreader": { + "get": { + "tags": [ + "KitchenReader" + ], + "parameters": [ + { + "name": "$count", + "in": "query", + "description": "Whether to include the total count of items matching the query in the result", + "schema": { + "enum": [ + "true", + "false" + ], + "type": "string" + } + }, + { + "name": "$filter", + "in": "query", + "description": "The filter to apply to the query", + "schema": { + "type": "string" + } + }, + { + "name": "$orderby", + "in": "query", + "description": "The comma-separated list of ordering instructions to apply to the query", + "schema": { + "type": "string" + } + }, + { + "name": "$select", + "in": "query", + "description": "The comma-separated list of fields to return in the results", + "schema": { + "type": "string" + } + }, + { + "name": "$skip", + "in": "query", + "description": "The number of items to skip", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + { + "name": "$top", + "in": "query", + "description": "The number of items to return", + "schema": { + "minimum": 1, + "type": "integer" + } + }, + { + "name": "__includeDeleted", + "in": "query", + "description": "Include deleted items in the response ('true' or 'false').", + "schema": { + "enum": [ + "true", + "false" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/tables/kitchenreader/{id}": { + "get": { + "tags": [ + "KitchenReader" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "__includeDeleted", + "in": "query", + "description": "Include deleted items in the response ('true' or 'false').", + "schema": { + "enum": [ + "true", + "false" + ], + "type": "string" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "ETag value to conditionally fetch the entity", + "schema": { + "type": "string" + } + }, + { + "name": "If-Modified-Since", + "in": "header", + "description": "Timestamp to conditionally fetch the entity", + "schema": { + "type": "string", + "format": "date-time" + } + } + ], + "responses": { + "200": { + "description": "OK", + "headers": { + "ETag": { + "description": "The ETag value for the entity", + "schema": { + "type": "string" + } + }, + "Last-Modified": { + "description": "The last modified timestamp for the entity", + "schema": { + "type": "string", + "format": "date-time" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "304": { + "description": "Not Modified" + }, + "404": { + "description": "Not Found" + }, + "410": { + "description": "Gone" + } + } + } + }, + "/tables/kitchensink": { + "post": { + "tags": [ + "KitchenSink" + ], + "requestBody": { + "description": "The entity to process.", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "headers": { + "ETag": { + "description": "The ETag value for the entity", + "schema": { + "type": "string" + } + }, + "Last-Modified": { + "description": "The last modified timestamp for the entity", + "schema": { + "type": "string", + "format": "date-time" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "409": { + "description": "Conflict", + "headers": { + "ETag": { + "description": "The ETag value for the entity", + "schema": { + "type": "string" + } + }, + "Last-Modified": { + "description": "The last modified timestamp for the entity", + "schema": { + "type": "string", + "format": "date-time" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "412": { + "description": "Precondition Failed", + "headers": { + "ETag": { + "description": "The ETag value for the entity", + "schema": { + "type": "string" + } + }, + "Last-Modified": { + "description": "The last modified timestamp for the entity", + "schema": { + "type": "string", + "format": "date-time" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + }, + "get": { + "tags": [ + "KitchenSink" + ], + "parameters": [ + { + "name": "$count", + "in": "query", + "description": "Whether to include the total count of items matching the query in the result", + "schema": { + "enum": [ + "true", + "false" + ], + "type": "string" + } + }, + { + "name": "$filter", + "in": "query", + "description": "The filter to apply to the query", + "schema": { + "type": "string" + } + }, + { + "name": "$orderby", + "in": "query", + "description": "The comma-separated list of ordering instructions to apply to the query", + "schema": { + "type": "string" + } + }, + { + "name": "$select", + "in": "query", + "description": "The comma-separated list of fields to return in the results", + "schema": { + "type": "string" + } + }, + { + "name": "$skip", + "in": "query", + "description": "The number of items to skip", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + { + "name": "$top", + "in": "query", + "description": "The number of items to return", + "schema": { + "minimum": 1, + "type": "integer" + } + }, + { + "name": "__includeDeleted", + "in": "query", + "description": "Include deleted items in the response ('true' or 'false').", + "schema": { + "enum": [ + "true", + "false" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/tables/kitchensink/{id}": { + "delete": { + "tags": [ + "KitchenSink" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "If-Match", + "in": "header", + "description": "ETag value to conditionally fetch the entity", + "schema": { + "type": "string" + } + }, + { + "name": "If-Unmodified-Since", + "in": "header", + "description": "Timestamp to conditionally fetch the entity", + "schema": { + "type": "string", + "format": "date-time" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "Not Found" + }, + "410": { + "description": "Gone" + }, + "409": { + "description": "Conflict", + "headers": { + "ETag": { + "description": "The ETag value for the entity", + "schema": { + "type": "string" + } + }, + "Last-Modified": { + "description": "The last modified timestamp for the entity", + "schema": { + "type": "string", + "format": "date-time" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "412": { + "description": "Precondition Failed", + "headers": { + "ETag": { + "description": "The ETag value for the entity", + "schema": { + "type": "string" + } + }, + "Last-Modified": { + "description": "The last modified timestamp for the entity", + "schema": { + "type": "string", + "format": "date-time" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + }, + "get": { + "tags": [ + "KitchenSink" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "__includeDeleted", + "in": "query", + "description": "Include deleted items in the response ('true' or 'false').", + "schema": { + "enum": [ + "true", + "false" + ], + "type": "string" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "ETag value to conditionally fetch the entity", + "schema": { + "type": "string" + } + }, + { + "name": "If-Modified-Since", + "in": "header", + "description": "Timestamp to conditionally fetch the entity", + "schema": { + "type": "string", + "format": "date-time" + } + } + ], + "responses": { + "200": { + "description": "OK", + "headers": { + "ETag": { + "description": "The ETag value for the entity", + "schema": { + "type": "string" + } + }, + "Last-Modified": { + "description": "The last modified timestamp for the entity", + "schema": { + "type": "string", + "format": "date-time" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "304": { + "description": "Not Modified" + }, + "404": { + "description": "Not Found" + }, + "410": { + "description": "Gone" + } + } + }, + "put": { + "tags": [ + "KitchenSink" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "__includeDeleted", + "in": "query", + "description": "Include deleted items in the response ('true' or 'false').", + "schema": { + "enum": [ + "true", + "false" + ], + "type": "string" + } + }, + { + "name": "If-Match", + "in": "header", + "description": "ETag value to conditionally fetch the entity", + "schema": { + "type": "string" + } + }, + { + "name": "If-Unmodified-Since", + "in": "header", + "description": "Timestamp to conditionally fetch the entity", + "schema": { + "type": "string", + "format": "date-time" + } + } + ], + "requestBody": { + "description": "The entity to process.", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "headers": { + "ETag": { + "description": "The ETag value for the entity", + "schema": { + "type": "string" + } + }, + "Last-Modified": { + "description": "The last modified timestamp for the entity", + "schema": { + "type": "string", + "format": "date-time" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "Not Found" + }, + "410": { + "description": "Gone" + }, + "409": { + "description": "Conflict", + "headers": { + "ETag": { + "description": "The ETag value for the entity", + "schema": { + "type": "string" + } + }, + "Last-Modified": { + "description": "The last modified timestamp for the entity", + "schema": { + "type": "string", + "format": "date-time" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "412": { + "description": "Precondition Failed", + "headers": { + "ETag": { + "description": "The ETag value for the entity", + "schema": { + "type": "string" + } + }, + "Last-Modified": { + "description": "The last modified timestamp for the entity", + "schema": { + "type": "string", + "format": "date-time" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + } + }, + "/tables/TodoItem": { + "post": { + "tags": [ + "TodoItem" + ], + "requestBody": { + "description": "The entity to process.", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "headers": { + "ETag": { + "description": "The ETag value for the entity", + "schema": { + "type": "string" + } + }, + "Last-Modified": { + "description": "The last modified timestamp for the entity", + "schema": { + "type": "string", + "format": "date-time" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "409": { + "description": "Conflict", + "headers": { + "ETag": { + "description": "The ETag value for the entity", + "schema": { + "type": "string" + } + }, + "Last-Modified": { + "description": "The last modified timestamp for the entity", + "schema": { + "type": "string", + "format": "date-time" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "412": { + "description": "Precondition Failed", + "headers": { + "ETag": { + "description": "The ETag value for the entity", + "schema": { + "type": "string" + } + }, + "Last-Modified": { + "description": "The last modified timestamp for the entity", + "schema": { + "type": "string", + "format": "date-time" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + }, + "get": { + "tags": [ + "TodoItem" + ], + "parameters": [ + { + "name": "$count", + "in": "query", + "description": "Whether to include the total count of items matching the query in the result", + "schema": { + "enum": [ + "true", + "false" + ], + "type": "string" + } + }, + { + "name": "$filter", + "in": "query", + "description": "The filter to apply to the query", + "schema": { + "type": "string" + } + }, + { + "name": "$orderby", + "in": "query", + "description": "The comma-separated list of ordering instructions to apply to the query", + "schema": { + "type": "string" + } + }, + { + "name": "$select", + "in": "query", + "description": "The comma-separated list of fields to return in the results", + "schema": { + "type": "string" + } + }, + { + "name": "$skip", + "in": "query", + "description": "The number of items to skip", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + { + "name": "$top", + "in": "query", + "description": "The number of items to return", + "schema": { + "minimum": 1, + "type": "integer" + } + }, + { + "name": "__includeDeleted", + "in": "query", + "description": "Include deleted items in the response ('true' or 'false').", + "schema": { + "enum": [ + "true", + "false" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/tables/TodoItem/{id}": { + "delete": { + "tags": [ + "TodoItem" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "If-Match", + "in": "header", + "description": "ETag value to conditionally fetch the entity", + "schema": { + "type": "string" + } + }, + { + "name": "If-Unmodified-Since", + "in": "header", + "description": "Timestamp to conditionally fetch the entity", + "schema": { + "type": "string", + "format": "date-time" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "Not Found" + }, + "410": { + "description": "Gone" + }, + "409": { + "description": "Conflict", + "headers": { + "ETag": { + "description": "The ETag value for the entity", + "schema": { + "type": "string" + } + }, + "Last-Modified": { + "description": "The last modified timestamp for the entity", + "schema": { + "type": "string", + "format": "date-time" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "412": { + "description": "Precondition Failed", + "headers": { + "ETag": { + "description": "The ETag value for the entity", + "schema": { + "type": "string" + } + }, + "Last-Modified": { + "description": "The last modified timestamp for the entity", + "schema": { + "type": "string", + "format": "date-time" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + }, + "get": { + "tags": [ + "TodoItem" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "__includeDeleted", + "in": "query", + "description": "Include deleted items in the response ('true' or 'false').", + "schema": { + "enum": [ + "true", + "false" + ], + "type": "string" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "ETag value to conditionally fetch the entity", + "schema": { + "type": "string" + } + }, + { + "name": "If-Modified-Since", + "in": "header", + "description": "Timestamp to conditionally fetch the entity", + "schema": { + "type": "string", + "format": "date-time" + } + } + ], + "responses": { + "200": { + "description": "OK", + "headers": { + "ETag": { + "description": "The ETag value for the entity", + "schema": { + "type": "string" + } + }, + "Last-Modified": { + "description": "The last modified timestamp for the entity", + "schema": { + "type": "string", + "format": "date-time" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "304": { + "description": "Not Modified" + }, + "404": { + "description": "Not Found" + }, + "410": { + "description": "Gone" + } + } + }, + "put": { + "tags": [ + "TodoItem" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "__includeDeleted", + "in": "query", + "description": "Include deleted items in the response ('true' or 'false').", + "schema": { + "enum": [ + "true", + "false" + ], + "type": "string" + } + }, + { + "name": "If-Match", + "in": "header", + "description": "ETag value to conditionally fetch the entity", + "schema": { + "type": "string" + } + }, + { + "name": "If-Unmodified-Since", + "in": "header", + "description": "Timestamp to conditionally fetch the entity", + "schema": { + "type": "string", + "format": "date-time" + } + } + ], + "requestBody": { + "description": "The entity to process.", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "headers": { + "ETag": { + "description": "The ETag value for the entity", + "schema": { + "type": "string" + } + }, + "Last-Modified": { + "description": "The last modified timestamp for the entity", + "schema": { + "type": "string", + "format": "date-time" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "Not Found" + }, + "410": { + "description": "Gone" + }, + "409": { + "description": "Conflict", + "headers": { + "ETag": { + "description": "The ETag value for the entity", + "schema": { + "type": "string" + } + }, + "Last-Modified": { + "description": "The last modified timestamp for the entity", + "schema": { + "type": "string", + "format": "date-time" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "412": { + "description": "Precondition Failed", + "headers": { + "ETag": { + "description": "The ETag value for the entity", + "schema": { + "type": "string" + } + }, + "Last-Modified": { + "description": "The last modified timestamp for the entity", + "schema": { + "type": "string", + "format": "date-time" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + } + } + }, + "components": { }, + "tags": [ + { + "name": "KitchenReader" + }, + { + "name": "KitchenSink" + }, + { + "name": "TodoItem" + } + ] +} \ No newline at end of file diff --git a/tests/CommunityToolkit.Datasync.TestCommon/Databases/MongoDB/MongoDBContext.cs b/tests/CommunityToolkit.Datasync.TestCommon/Databases/MongoDB/MongoDBContext.cs index 2acd0c86..68ea237c 100644 --- a/tests/CommunityToolkit.Datasync.TestCommon/Databases/MongoDB/MongoDBContext.cs +++ b/tests/CommunityToolkit.Datasync.TestCommon/Databases/MongoDB/MongoDBContext.cs @@ -59,20 +59,6 @@ public async Task PopulateDatabaseAsync() return; } - // Create the indices required for all the tests. - //List> indices = []; - //string[] props = ["BestPictureWinner", "Duration", "Rating", "ReleaseDate", "Title", "Year", "UpdatedAt", "Deleted"]; - //foreach (string prop in props) - //{ - // indices.AddRange(GetCompoundIndexDefinitions(prop)); - //} - - //indices.AddRange(GetCompoundIndexDefinitions("UpdatedAt", "Deleted", includeId: false)); - //indices.AddRange(GetCompoundIndexDefinitions("Title", "Year")); - //indices.AddRange(GetCompoundIndexDefinitions("Year", "Title")); - //await Movies.Indexes.CreateManyAsync(indices); - - // Now populate the database with the test data, after the indices are defined. foreach (MongoDBMovie movie in TestData.Movies.OfType()) { movie.UpdatedAt = DateTimeOffset.UtcNow; @@ -81,68 +67,4 @@ public async Task PopulateDatabaseAsync() await Movies.InsertOneAsync(movie, options); } } - - private static IEnumerable> GetCompoundIndexDefinitions(string field) - { - return [ - new CreateIndexModel(Builders.IndexKeys.Combine( - Builders.IndexKeys.Ascending(field), - Builders.IndexKeys.Ascending("_id") - )), - new CreateIndexModel(Builders.IndexKeys.Combine( - Builders.IndexKeys.Descending(field), - Builders.IndexKeys.Ascending("_id") - )) - ]; - } - - private static IEnumerable> GetCompoundIndexDefinitions(string field1, string field2, bool includeId = true) - { - if (includeId) - { - return [ - new CreateIndexModel(Builders.IndexKeys.Combine( - Builders.IndexKeys.Ascending(field1), - Builders.IndexKeys.Ascending(field2), - Builders.IndexKeys.Ascending("_id") - )), - new CreateIndexModel(Builders.IndexKeys.Combine( - Builders.IndexKeys.Ascending(field1), - Builders.IndexKeys.Descending(field2), - Builders.IndexKeys.Ascending("_id") - )), - new CreateIndexModel(Builders.IndexKeys.Combine( - Builders.IndexKeys.Descending(field1), - Builders.IndexKeys.Ascending(field2), - Builders.IndexKeys.Ascending("_id") - )), - new CreateIndexModel(Builders.IndexKeys.Combine( - Builders.IndexKeys.Descending(field1), - Builders.IndexKeys.Descending(field2), - Builders.IndexKeys.Ascending("_id") - )), - ]; - } - else - { - return [ - new CreateIndexModel(Builders.IndexKeys.Combine( - Builders.IndexKeys.Ascending(field1), - Builders.IndexKeys.Ascending(field2) - )), - new CreateIndexModel(Builders.IndexKeys.Combine( - Builders.IndexKeys.Ascending(field1), - Builders.IndexKeys.Descending(field2) - )), - new CreateIndexModel(Builders.IndexKeys.Combine( - Builders.IndexKeys.Descending(field1), - Builders.IndexKeys.Ascending(field2) - )), - new CreateIndexModel(Builders.IndexKeys.Combine( - Builders.IndexKeys.Descending(field1), - Builders.IndexKeys.Descending(field2) - )), - ]; - } - } } diff --git a/tests/CommunityToolkit.Datasync.TestService/CommunityToolkit.Datasync.TestService.csproj b/tests/CommunityToolkit.Datasync.TestService/CommunityToolkit.Datasync.TestService.csproj index f77dbd35..d3a5262c 100644 --- a/tests/CommunityToolkit.Datasync.TestService/CommunityToolkit.Datasync.TestService.csproj +++ b/tests/CommunityToolkit.Datasync.TestService/CommunityToolkit.Datasync.TestService.csproj @@ -10,8 +10,9 @@ - + + diff --git a/tests/CommunityToolkit.Datasync.TestService/Program.cs b/tests/CommunityToolkit.Datasync.TestService/Program.cs index 7d517f17..08df7255 100644 --- a/tests/CommunityToolkit.Datasync.TestService/Program.cs +++ b/tests/CommunityToolkit.Datasync.TestService/Program.cs @@ -4,10 +4,11 @@ using CommunityToolkit.Datasync.Server; using CommunityToolkit.Datasync.Server.InMemory; -using TestData = CommunityToolkit.Datasync.TestCommon.TestData; using CommunityToolkit.Datasync.TestCommon.Databases; -using Microsoft.OData.ModelBuilder; using CommunityToolkit.Datasync.TestService.AccessControlProviders; +using Microsoft.OData.ModelBuilder; + +using TestData = CommunityToolkit.Datasync.TestCommon.TestData; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); @@ -25,12 +26,15 @@ builder.Services.AddSingleton>(new MovieAccessControlProvider()); +builder.Services.AddOpenApi(); + builder.Services.AddControllers(); WebApplication app = builder.Build(); app.UseHttpsRedirection(); app.UseAuthorization(); app.MapControllers(); +app.MapOpenApi(); app.Run();