From 7bdf1ab51b573d75e76d25a5b8c317c86f23e4f1 Mon Sep 17 00:00:00 2001 From: Adrian Hall Date: Mon, 17 Jun 2024 10:13:37 -0700 Subject: [PATCH] (#40) Swashbuckle support. --- .github/workflows/SignedPackageFileList.txt | 1 + Datasync.Toolkit.sln | 18 +- ...Toolkit.Datasync.Server.Swashbuckle.csproj | 19 + .../DatasyncDocumentFilter.cs | 271 +++++ .../DatasyncOperationExtensions.cs | 124 ++ .../SwaggerGenExtensions.cs | 25 + ...it.Datasync.Server.Swashbuckle.Test.csproj | 22 + .../GlobalSuppressions.cs | 7 + .../Service/Controllers.cs | 57 + .../Service/Models.cs | 38 + .../Service/ServiceDbContext.cs | 24 + .../Service/SwashbuckleServer.cs | 65 + .../Swashbuckle_Tests.cs | 120 ++ .../swagger.json | 1082 +++++++++++++++++ 14 files changed, 1871 insertions(+), 2 deletions(-) create mode 100644 src/CommunityToolkit.Datasync.Server.Swashbuckle/CommunityToolkit.Datasync.Server.Swashbuckle.csproj create mode 100644 src/CommunityToolkit.Datasync.Server.Swashbuckle/DatasyncDocumentFilter.cs create mode 100644 src/CommunityToolkit.Datasync.Server.Swashbuckle/DatasyncOperationExtensions.cs create mode 100644 src/CommunityToolkit.Datasync.Server.Swashbuckle/SwaggerGenExtensions.cs create mode 100644 tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/CommunityToolkit.Datasync.Server.Swashbuckle.Test.csproj create mode 100644 tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/GlobalSuppressions.cs create mode 100644 tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/Service/Controllers.cs create mode 100644 tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/Service/Models.cs create mode 100644 tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/Service/ServiceDbContext.cs create mode 100644 tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/Service/SwashbuckleServer.cs create mode 100644 tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/Swashbuckle_Tests.cs create mode 100644 tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/swagger.json diff --git a/.github/workflows/SignedPackageFileList.txt b/.github/workflows/SignedPackageFileList.txt index 0a36e6b..3dff859 100644 --- a/.github/workflows/SignedPackageFileList.txt +++ b/.github/workflows/SignedPackageFileList.txt @@ -6,3 +6,4 @@ **/CommunityToolkit.Datasync.Server.InMemory **/CommunityToolkit.Datasync.Server.LiteDb **/CommunityToolkit.Datasync.Server.NSwag +**/CommunityToolkit.Datasync.Server.Swashbuckle diff --git a/Datasync.Toolkit.sln b/Datasync.Toolkit.sln index 0092450..7870895 100644 --- a/Datasync.Toolkit.sln +++ b/Datasync.Toolkit.sln @@ -51,9 +51,13 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{2D2A6EFC .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}" +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}" 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}" +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}" +EndProject +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}" +EndProject +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}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -133,6 +137,14 @@ Global {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 + {F578EC54-454F-4114-AC37-C83A7831E783}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F578EC54-454F-4114-AC37-C83A7831E783}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F578EC54-454F-4114-AC37-C83A7831E783}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F578EC54-454F-4114-AC37-C83A7831E783}.Release|Any CPU.Build.0 = Release|Any CPU + {45D47A4E-AD58-40C8-B4CC-95BC888C47A7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {45D47A4E-AD58-40C8-B4CC-95BC888C47A7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {45D47A4E-AD58-40C8-B4CC-95BC888C47A7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {45D47A4E-AD58-40C8-B4CC-95BC888C47A7}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -156,6 +168,8 @@ Global {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} + {F578EC54-454F-4114-AC37-C83A7831E783} = {D59F1489-5D74-4F52-B78B-88037EAB2838} + {45D47A4E-AD58-40C8-B4CC-95BC888C47A7} = {84AD662A-4B9E-4E64-834D-72529FB7FCE5} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {78A935E9-8F14-448A-BEDF-360FB742F14E} diff --git a/src/CommunityToolkit.Datasync.Server.Swashbuckle/CommunityToolkit.Datasync.Server.Swashbuckle.csproj b/src/CommunityToolkit.Datasync.Server.Swashbuckle/CommunityToolkit.Datasync.Server.Swashbuckle.csproj new file mode 100644 index 0000000..5945d74 --- /dev/null +++ b/src/CommunityToolkit.Datasync.Server.Swashbuckle/CommunityToolkit.Datasync.Server.Swashbuckle.csproj @@ -0,0 +1,19 @@ + + + Provides necessary capabilities for supporting the Datasync server library when using Swashbuckle for creating OpenAPI definitions. + + + + + + + + + + + + + + + + diff --git a/src/CommunityToolkit.Datasync.Server.Swashbuckle/DatasyncDocumentFilter.cs b/src/CommunityToolkit.Datasync.Server.Swashbuckle/DatasyncDocumentFilter.cs new file mode 100644 index 0000000..ef7cb83 --- /dev/null +++ b/src/CommunityToolkit.Datasync.Server.Swashbuckle/DatasyncDocumentFilter.cs @@ -0,0 +1,271 @@ +// 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.Mvc.ApiExplorer; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; + +namespace CommunityToolkit.Datasync.Server.Swashbuckle; + +/// +/// An that adds the relevant schema and paramter definitions +/// to generate an OpenAPI v3.0.3 definition for Datasync +/// controllers. +/// +/// +/// Creates a new . +/// +/// The assembly to query for TableController instances, if any. If none is provided, the calling assembly is queried. +public class DatasyncDocumentFilter(Assembly? assemblyToQuery = null) : IDocumentFilter +{ + // The list of operation types. + private enum OpType + { + Create, + Delete, + GetById, + List, + Replace + } + + // The names of the QueryAsync() and CreateAsync() methods in the TableController. + private const string queryMethod = nameof(TableController.QueryAsync); + private const string createMethod = nameof(TableController.CreateAsync); + + // The list of entity names that have already had their schema adjusted. + private readonly List processedEntityNames = []; + + /// + /// Applies the necessary changes to the . + /// + /// The to edit. + /// The filter context. + public void Apply(OpenApiDocument document, DocumentFilterContext context) + { + foreach (Type controller in GetAllTableControllers(assemblyToQuery)) + { + if (TryGetTableEntityType(controller, out Type? entityType)) + { + string? routePath = GetRoutePathFromContext(context, controller); + if (routePath != null) + { + ProcessController(entityType!, routePath, document, context); + } + } + } + } + + /// + /// Applies the necessary changes to the for a single controller. + /// + /// The type of the entity being processed by the controller. + /// The path used to access the controller in a HTTP request. + /// The to edit. + /// The filter context. + internal void ProcessController(Type entityType, string routePath, OpenApiDocument document, DocumentFilterContext context) + { + // Get the base paths managed by this controller. + string allEntitiesPath = $"/{routePath}"; + string singleEntityPath = $"/{routePath}/{{id}}"; + + // Get the various operations + Dictionary operations = []; + AddOperationIfPresent(operations, OpType.Create, document, allEntitiesPath, OperationType.Post); + AddOperationIfPresent(operations, OpType.Delete, document, singleEntityPath, OperationType.Delete); + AddOperationIfPresent(operations, OpType.GetById, document, singleEntityPath, OperationType.Get); + AddOperationIfPresent(operations, OpType.List, document, allEntitiesPath, OperationType.Get); + AddOperationIfPresent(operations, OpType.Replace, document, singleEntityPath, OperationType.Put); + + // Make the system properties in the entity read-only + if (!this.processedEntityNames.Contains(entityType.Name)) + { + // Generate a schema for the entity if it doesn't exist. + if (context.SchemaRepository.Schemas.GetValueOrDefault(entityType.Name) == null) + { + _ = context.SchemaGenerator.GenerateSchema(entityType, context.SchemaRepository); + } + + // This is a Datasync schema, so update the schema for the datasync attributes. + context.SchemaRepository.Schemas[entityType.Name].MakeSystemPropertiesReadonly(); + context.SchemaRepository.Schemas[entityType.Name].UnresolvedReference = false; + context.SchemaRepository.Schemas[entityType.Name].Reference = new OpenApiReference + { + Id = entityType.Name, + Type = ReferenceType.Schema + }; + this.processedEntityNames.Add(entityType.Name); + } + + Type listEntityType = typeof(Page<>).MakeGenericType(entityType); + OpenApiSchema listSchemaRef = context.SchemaRepository.Schemas.GetValueOrDefault(listEntityType.Name) + ?? context.SchemaGenerator.GenerateSchema(listEntityType, context.SchemaRepository); + + foreach (KeyValuePair operation in operations) + { + // Each operation also has certain modifications. + switch (operation.Key) + { + case OpType.Create: + // Request Edits + operation.Value.AddConditionalHeader(true); + + // Response Edits + operation.Value.AddResponseWithContent("201", "Created", context.SchemaRepository.Schemas[entityType.Name]); + operation.Value.Responses["400"] = new OpenApiResponse { Description = "Bad Request" }; + operation.Value.AddConflictResponse(context.SchemaRepository.Schemas[entityType.Name]); + break; + + case OpType.Delete: + // Request Edits + operation.Value.AddConditionalHeader(); + + // Response Edits + operation.Value.Responses["204"] = new OpenApiResponse { Description = "No Content" }; + operation.Value.Responses["404"] = new OpenApiResponse { Description = "Not Found" }; + operation.Value.Responses["410"] = new OpenApiResponse { Description = "Gone" }; + operation.Value.AddConflictResponse(context.SchemaRepository.Schemas[entityType.Name]); + break; + + case OpType.GetById: + // Request Edits + operation.Value.AddConditionalHeader(true); + + // Response Edits + operation.Value.AddResponseWithContent("200", "OK", context.SchemaRepository.Schemas[entityType.Name]); + operation.Value.Responses["304"] = new OpenApiResponse { Description = "Not Modified" }; + operation.Value.Responses["404"] = new OpenApiResponse { Description = "Not Found" }; + break; + + case OpType.List: + // Request Edits + operation.Value.AddODataQueryParameters(); + + // Response Edits + operation.Value.AddResponseWithContent("200", "OK", listSchemaRef); + operation.Value.Responses["400"] = new OpenApiResponse { Description = "Bad Request" }; + break; + + case OpType.Replace: + // Request Edits + operation.Value.AddConditionalHeader(); + + // Response Edits + operation.Value.AddResponseWithContent("200", "OK", context.SchemaRepository.Schemas[entityType.Name]); + operation.Value.Responses["400"] = new OpenApiResponse { Description = "Bad Request" }; + operation.Value.Responses["404"] = new OpenApiResponse { Description = "Not Found" }; + operation.Value.Responses["410"] = new OpenApiResponse { Description = "Gone" }; + operation.Value.AddConflictResponse(context.SchemaRepository.Schemas[entityType.Name]); + break; + } + } + } + + /// + /// Adds the relevant operation to the dictionary if it is present. + /// + /// + /// A developer can override the actions to disable + /// any operation (e.g. to create a read-only controller). So we need to check to ensure + /// that every element is there. + /// + /// The operations dictionary to modify. + /// The internal operation type (Create, Delete, Query, etc.) + /// The being processed. + /// The expected path for the operation type. + /// The operation type being processed. + private static void AddOperationIfPresent(Dictionary operations, OpType opType, OpenApiDocument document, string path, OperationType operationType) + { + if (document.Paths.TryGetValue(path, out OpenApiPathItem? pathValue)) + { + if (pathValue!.Operations.TryGetValue(operationType, out OpenApiOperation? operation)) + { + operations[opType] = operation!; + } + } + } + + /// + /// Retrieves the entity type for a . + /// + /// The type for the controller. + /// The type for the entity, or null if the controller doesn't have an entity type. + /// true if the table controller has an entity type. + internal static bool TryGetTableEntityType(Type controllerType, out Type? entityType) + { + entityType = controllerType.BaseType?.GetGenericArguments().FirstOrDefault(); + return entityType != null; + } + + /// + /// Retrieves the route path for a controller. + /// + /// The filter context. + /// The controller to check. + /// The route path for the controller. + internal static string? GetRoutePathFromContext(DocumentFilterContext context, Type controller) + => context.ApiDescriptions.FirstOrDefault(m => IsApiDescriptionForController(m, controller))?.RelativePath; + + /// + /// Determines if the controller type is represented by the API Description. + /// + /// The being handled. + /// The type of the controller being used. + /// true if the Api description represents the controller. + internal static bool IsApiDescriptionForController(ApiDescription description, Type controllerType) + => description.TryGetMethodInfo(out MethodInfo methodInfo) + && methodInfo.ReflectedType == controllerType + && (methodInfo.Name.Equals(queryMethod) || methodInfo.Name.Equals(createMethod)); + + /// + /// Returns a list of all table controllers in the provided assembly. + /// + /// The assembly to query. Be default, the calling assembly is queried. + /// The list of table controllers in the assembly. + internal static List GetAllTableControllers(Assembly? assembly) + => (assembly ?? Assembly.GetCallingAssembly()).GetTypes().Where(IsTableController).ToList(); + + /// + /// Determines if the controller type provided is a datasync table controller. + /// + /// The type of the table controller. + /// true if the type is a datasync table controller. + internal static bool IsTableController(Type type) + { + if (!type.IsAbstract && type.BaseType != null && type.BaseType.IsGenericType == true) + { + if (type.GetCustomAttribute() != null) + { + return true; + } + } + + return false; + } + + /// + /// A type representing a single page of entities. + /// + /// The type of the entity. + [ExcludeFromCodeCoverage(Justification = "Model class - coverage not needed")] + internal class Page + { + /// + /// The list of entities in this page of the results. + /// + public IEnumerable Items { get; } = []; + + /// + /// The count of all the entities in the result set. + /// + public long? Count { get; } + + /// + /// The URI to the next page of entities. + /// + public Uri? NextLink { get; } + } +} diff --git a/src/CommunityToolkit.Datasync.Server.Swashbuckle/DatasyncOperationExtensions.cs b/src/CommunityToolkit.Datasync.Server.Swashbuckle/DatasyncOperationExtensions.cs new file mode 100644 index 0000000..e6dcbc4 --- /dev/null +++ b/src/CommunityToolkit.Datasync.Server.Swashbuckle/DatasyncOperationExtensions.cs @@ -0,0 +1,124 @@ +// 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.OpenApi.Models; + +namespace CommunityToolkit.Datasync.Server.Swashbuckle; + +internal static class DatasyncOperationExtensions +{ + private const string JsonMediaType = "application/json"; + private static readonly string[] SystemProperties = ["updatedAt", "version", "deleted"]; + + /// + /// Adds an appropriate conditional header (either If-Match or If-None-Match to the list of + /// allowed headers. + /// + /// The reference. + /// If true, add a If-None-Match header. + internal static void AddConditionalHeader(this OpenApiOperation operation, bool ifNoneMatch = false) + { + string headerName = ifNoneMatch ? "If-None-Match" : "If-Match"; + string description = ifNoneMatch + ? "Conditionally execute only if the entity version does not match the provided string (RFC 9110 13.1.2)." + : "Conditionally execute only if the entity version matches the provided string (RFC 9110 13.1.1)."; + + operation.Parameters.Add(new OpenApiParameter + { + Name = headerName, + Description = description, + In = ParameterLocation.Header, + Required = false, + Schema = new OpenApiSchema { Type = "string" } + }); + } + + /// + /// Adds the OData query parameter to the operation. + /// + /// The reference. + /// The name of the query parameter. + /// The OpenAPI type for the query parameter. + /// The OpenAPI description for the query parameter. + internal static void AddODataQueryParameter(this OpenApiOperation operation, string parameterName, string parameterType, string description) + { + operation.Parameters.Add(new OpenApiParameter + { + Name = parameterName, + Description = description, + In = ParameterLocation.Query, + Required = false, + Schema = new OpenApiSchema { Type = parameterType } + }); + } + + /// + /// Adds the OData query parameters for the GET list operation. + /// + /// The reference. + internal static void AddODataQueryParameters(this OpenApiOperation operation) + { + operation.AddODataQueryParameter("$count", "boolean", "If true, return the total number of items matched by the filter"); + operation.AddODataQueryParameter("$filter", "string", "An OData filter describing the entities to be returned"); + operation.AddODataQueryParameter("$orderby", "string", "A comma-separated list of ordering instructions. Each ordering instruction is a field name with an optional direction (asc or desc)."); + operation.AddODataQueryParameter("$select", "string", "A comma-separated list of fields to be returned in the result set."); + operation.AddODataQueryParameter("$skip", "integer", "The number of items in the list to skip for paging support."); + operation.AddODataQueryParameter("$top", "integer", "The number of items in the list to return for paging support."); + operation.AddODataQueryParameter("__includedeleted", "boolean", "If true, soft-deleted items are returned as well as non-deleted items."); + } + + /// + /// Adds or replaces a response with JSON content and an ETag. + /// + /// The to modify. + /// The HTTP status code to model. + /// The description of the HTTP status code. + /// The schema of the entity to return. + internal static void AddResponseWithContent(this OpenApiOperation operation, string statusCode, string description, OpenApiSchema schema) + { + OpenApiResponse response = new() + { + Description = description, + Content = new Dictionary + { + [JsonMediaType] = new OpenApiMediaType { Schema = schema } + } + }; + string etagDescription = statusCode is "409" or "412" + ? "The opaque versioning identifier of the conflicting entity" + : "The opaque versioning identifier of the entity"; + response.Headers.Add("ETag", new OpenApiHeader + { + Schema = new OpenApiSchema { Type = "string" }, + Description = $"{etagDescription}, per RFC 9110 8.8.3." + }); + operation.Responses[statusCode] = response; + } + + /// + /// Adds or replaces the 409/412 Conflict/Precondition Failed response. + /// + /// The to modify. + /// The schema of the entity to return. + internal static void AddConflictResponse(this OpenApiOperation operation, OpenApiSchema schema) + { + operation.AddResponseWithContent("409", "Conflict", schema); + operation.AddResponseWithContent("412", "Precondition failed", schema); + } + + /// + /// Makes the system properties in the schema read-only. + /// + /// The to edit. + public static void MakeSystemPropertiesReadonly(this OpenApiSchema schema) + { + foreach (KeyValuePair property in schema.Properties) + { + if (SystemProperties.Contains(property.Key)) + { + property.Value.ReadOnly = true; + } + } + } +} diff --git a/src/CommunityToolkit.Datasync.Server.Swashbuckle/SwaggerGenExtensions.cs b/src/CommunityToolkit.Datasync.Server.Swashbuckle/SwaggerGenExtensions.cs new file mode 100644 index 0000000..0a32c6e --- /dev/null +++ b/src/CommunityToolkit.Datasync.Server.Swashbuckle/SwaggerGenExtensions.cs @@ -0,0 +1,25 @@ +// 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.Extensions.DependencyInjection; +using Swashbuckle.AspNetCore.SwaggerGen; +using System.Reflection; + +namespace CommunityToolkit.Datasync.Server.Swashbuckle; + +/// +/// Extensions to enable the developer to wire in the Datasync Controllers into +/// a Swashbuckle SwaggerGen() pipeline. +/// +public static class SwaggerGenExtensions +{ + /// + /// Adds the Datasync to the SwaggerGen pipeline. + /// + /// The representing the Swashbuckle configuration pipeline. + public static void AddDatasyncControllers(this SwaggerGenOptions options) + { + options.DocumentFilter(Assembly.GetCallingAssembly()); + } +} diff --git a/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/CommunityToolkit.Datasync.Server.Swashbuckle.Test.csproj b/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/CommunityToolkit.Datasync.Server.Swashbuckle.Test.csproj new file mode 100644 index 0000000..22f9cd9 --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/CommunityToolkit.Datasync.Server.Swashbuckle.Test.csproj @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/GlobalSuppressions.cs b/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/GlobalSuppressions.cs new file mode 100644 index 0000000..07fa981 --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/GlobalSuppressions.cs @@ -0,0 +1,7 @@ +// 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. + + +[assembly: SuppressMessage("Performance", "SYSLIB1045:Convert to 'GeneratedRegexAttribute'.", Justification = "", Scope = "member", Target = "~M:CommunityToolkit.Datasync.Server.Swashbuckle.Test.Swashbuckle_Tests.NSwag_GeneratesSwagger~System.Threading.Tasks.Task")] diff --git a/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/Service/Controllers.cs b/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/Service/Controllers.cs new file mode 100644 index 0000000..ab6879f --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/Service/Controllers.cs @@ -0,0 +1,57 @@ +// 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.Swashbuckle.Test.Service; + +[ExcludeFromCodeCoverage] +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] +public class KitchenReaderController : ReadonlyTableController +{ + public KitchenReaderController(ServiceDbContext context, ILogger logger) : base() + { + Repository = new EntityTableRepository(context); + Logger = logger; + } +} + +[Route("tables/kitchensink")] +[ExcludeFromCodeCoverage] +public class KitchenSinkController : TableController +{ + public KitchenSinkController(ServiceDbContext context, ILogger logger) : base() + { + Repository = new EntityTableRepository(context); + Logger = logger; + } +} + +[Route("tables/[controller]")] +[ExcludeFromCodeCoverage] +public class TodoItemController : TableController +{ + public TodoItemController(ServiceDbContext context) : base() + { + Repository = new EntityTableRepository(context); + } +} diff --git a/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/Service/Models.cs b/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/Service/Models.cs new file mode 100644 index 0000000..c05a10f --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/Service/Models.cs @@ -0,0 +1,38 @@ +// 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; +using Microsoft.Spatial; + +namespace CommunityToolkit.Datasync.Server.Swashbuckle.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.Swashbuckle.Test/Service/ServiceDbContext.cs b/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/Service/ServiceDbContext.cs new file mode 100644 index 0000000..53c8087 --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Server.Swashbuckle.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.Swashbuckle.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.Swashbuckle.Test/Service/SwashbuckleServer.cs b/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/Service/SwashbuckleServer.cs new file mode 100644 index 0000000..72aaed6 --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/Service/SwashbuckleServer.cs @@ -0,0 +1,65 @@ +// 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.Swashbuckle.Test.Service; + +[ExcludeFromCodeCoverage] +internal static class SwashbuckleServer +{ + 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.AddSwaggerGen(options => options.AddDatasyncControllers()); + } + + public static void Configure(IApplicationBuilder builder) + { + builder.UseSwagger(); + builder.UseSwaggerUI(); + builder.UseRouting(); + builder.UseEndpoints(endpoints => endpoints.MapControllers()); + } +} diff --git a/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/Swashbuckle_Tests.cs b/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/Swashbuckle_Tests.cs new file mode 100644 index 0000000..42816cd --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/Swashbuckle_Tests.cs @@ -0,0 +1,120 @@ +// 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.Swashbuckle.Test.Service; +using CommunityToolkit.Datasync.TestCommon; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.TestHost; +using NSubstitute; +using Swashbuckle.AspNetCore.SwaggerGen; +using System.Reflection; +using Xunit; + +namespace CommunityToolkit.Datasync.Server.Swashbuckle.Test; + +[ExcludeFromCodeCoverage] +public class Swashbuckle_Tests +{ + private readonly TestServer server = SwashbuckleServer.CreateTestServer(); + + [Fact] + public async Task Swashbuckle_GeneratesSwagger() + { + HttpClient client = this.server.CreateClient(); + string actualContent = (await client.GetStringAsync("swagger/v1/swagger.json")).NormalizeContent(); + string expectedContent = Assembly.GetExecutingAssembly().ReadExternalFile("swagger.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\": \"[^\\\"]+\","); + //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 NSwag 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); + using StreamWriter output = new(Path.Combine(outputPath, "swashbuckle.out.json")); + output.Write(actualContent); + output.Flush(); + output.Close(); + } + + actualContent.Should().Be(expectedContent); + } + + [Theory] + [InlineData(typeof(TC1))] + [InlineData(typeof(TC2))] + [InlineData(typeof(TC3))] + [InlineData(typeof(TC4))] + [InlineData(typeof(TC5))] + public void IsTableController_ReturnsFalse_ForConditions(Type sut) + { + DatasyncDocumentFilter.IsTableController(sut).Should().BeFalse(); + } + + [Theory] + [InlineData(typeof(object), false, null)] + [InlineData(typeof(TC1), false, null)] + [InlineData(typeof(TC2), false, null)] + [InlineData(typeof(TC3), true, typeof(TodoItem))] + [InlineData(typeof(TC4), false, null)] + [InlineData(typeof(TC5), true, typeof(TodoItem))] + public void TryGetTableEntityType_Returns_CorrectValue(Type sut, bool expectedResult, Type? expectedEntityType) + { + bool actual = DatasyncDocumentFilter.TryGetTableEntityType(sut, out Type actualEntityType); + actual.Should().Be(expectedResult); + if (expectedEntityType == null) + { + actualEntityType.Should().BeNull(); + } + else + { + actualEntityType.Should().Be(expectedEntityType); + } + } + + [Fact] + public void GetRoutePathFromContext_ReturnsNullAppropriately() + { + DocumentFilterContext context = new([], Substitute.For(), new SchemaRepository()); + string routePath = DatasyncDocumentFilter.GetRoutePathFromContext(context, typeof(TC3)); + routePath.Should().BeNull(); + } + + [Fact] + public void GetAllTableController_ThisAssembly() + { + string[] expected = [ "KitchenReaderController", "KitchenSinkController", "TodoItemController" ]; + List controllerNames = DatasyncDocumentFilter.GetAllTableControllers(null).Select(x => x.Name).ToList(); + controllerNames.Should().BeEquivalentTo(expected); + } + + [Fact] + public void GetAllTableControllers_AlternateAssembly() + { + // Adjust this as necessary to the list of controllers in the TestService. + string[] expected = [ + "InMemoryKitchenSinkController", + "InMemoryMovieController", + "InMemoryPagedMovieController", + "InMemorySoftDeletedMovieController" + ]; + Assembly assembly = typeof(TestService.Controllers.InMemoryMovieController).Assembly; + List controllerNames = DatasyncDocumentFilter.GetAllTableControllers(assembly).Select(x => x.Name).ToList(); + controllerNames.Should().BeEquivalentTo(expected); + } + + class TC1 { } + class TC2 : ControllerBase { } + abstract class TC3 : TableController { } + abstract class TC4 : ControllerBase { } + class TC5 : TC4 { } +} diff --git a/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/swagger.json b/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/swagger.json new file mode 100644 index 0000000..59c886b --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/swagger.json @@ -0,0 +1,1082 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "CommunityToolkit.Datasync.Server.Swashbuckle.Test", + "version": "1.0" + }, + "paths": { + "/tables/kitchenreader": { + "get": { + "tags": [ + "KitchenReader" + ], + "parameters": [ + { + "name": "$count", + "in": "query", + "description": "If true, return the total number of items matched by the filter", + "schema": { + "type": "boolean" + } + }, + { + "name": "$filter", + "in": "query", + "description": "An OData filter describing the entities to be returned", + "schema": { + "type": "string" + } + }, + { + "name": "$orderby", + "in": "query", + "description": "A comma-separated list of ordering instructions. Each ordering instruction is a field name with an optional direction (asc or desc).", + "schema": { + "type": "string" + } + }, + { + "name": "$select", + "in": "query", + "description": "A comma-separated list of fields to be returned in the result set.", + "schema": { + "type": "string" + } + }, + { + "name": "$skip", + "in": "query", + "description": "The number of items in the list to skip for paging support.", + "schema": { + "type": "integer" + } + }, + { + "name": "$top", + "in": "query", + "description": "The number of items in the list to return for paging support.", + "schema": { + "type": "integer" + } + }, + { + "name": "__includedeleted", + "in": "query", + "description": "If true, soft-deleted items are returned as well as non-deleted items.", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "OK", + "headers": { + "ETag": { + "description": "The opaque versioning identifier of the entity, per RFC 9110 8.8.3.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KitchenSinkPage" + } + } + } + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/tables/kitchenreader/{id}": { + "get": { + "tags": [ + "KitchenReader" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "Conditionally execute only if the entity version does not match the provided string (RFC 9110 13.1.2).", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "headers": { + "ETag": { + "description": "The opaque versioning identifier of the entity, per RFC 9110 8.8.3.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KitchenSink" + } + } + } + }, + "304": { + "description": "Not Modified" + }, + "404": { + "description": "Not Found" + } + } + } + }, + "/tables/kitchensink": { + "post": { + "tags": [ + "KitchenSink" + ], + "parameters": [ + { + "name": "If-None-Match", + "in": "header", + "description": "Conditionally execute only if the entity version does not match the provided string (RFC 9110 13.1.2).", + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "Created", + "headers": { + "ETag": { + "description": "The opaque versioning identifier of the entity, per RFC 9110 8.8.3.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KitchenSink" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "409": { + "description": "Conflict", + "headers": { + "ETag": { + "description": "The opaque versioning identifier of the conflicting entity, per RFC 9110 8.8.3.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KitchenSink" + } + } + } + }, + "412": { + "description": "Precondition failed", + "headers": { + "ETag": { + "description": "The opaque versioning identifier of the conflicting entity, per RFC 9110 8.8.3.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KitchenSink" + } + } + } + } + } + }, + "get": { + "tags": [ + "KitchenSink" + ], + "parameters": [ + { + "name": "$count", + "in": "query", + "description": "If true, return the total number of items matched by the filter", + "schema": { + "type": "boolean" + } + }, + { + "name": "$filter", + "in": "query", + "description": "An OData filter describing the entities to be returned", + "schema": { + "type": "string" + } + }, + { + "name": "$orderby", + "in": "query", + "description": "A comma-separated list of ordering instructions. Each ordering instruction is a field name with an optional direction (asc or desc).", + "schema": { + "type": "string" + } + }, + { + "name": "$select", + "in": "query", + "description": "A comma-separated list of fields to be returned in the result set.", + "schema": { + "type": "string" + } + }, + { + "name": "$skip", + "in": "query", + "description": "The number of items in the list to skip for paging support.", + "schema": { + "type": "integer" + } + }, + { + "name": "$top", + "in": "query", + "description": "The number of items in the list to return for paging support.", + "schema": { + "type": "integer" + } + }, + { + "name": "__includedeleted", + "in": "query", + "description": "If true, soft-deleted items are returned as well as non-deleted items.", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "OK", + "headers": { + "ETag": { + "description": "The opaque versioning identifier of the entity, per RFC 9110 8.8.3.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KitchenSinkPage" + } + } + } + }, + "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": "Conditionally execute only if the entity version matches the provided string (RFC 9110 13.1.1).", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "description": "Not Found" + }, + "410": { + "description": "Gone" + }, + "409": { + "description": "Conflict", + "headers": { + "ETag": { + "description": "The opaque versioning identifier of the conflicting entity, per RFC 9110 8.8.3.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KitchenSink" + } + } + } + }, + "412": { + "description": "Precondition failed", + "headers": { + "ETag": { + "description": "The opaque versioning identifier of the conflicting entity, per RFC 9110 8.8.3.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KitchenSink" + } + } + } + } + } + }, + "get": { + "tags": [ + "KitchenSink" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "Conditionally execute only if the entity version does not match the provided string (RFC 9110 13.1.2).", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "headers": { + "ETag": { + "description": "The opaque versioning identifier of the entity, per RFC 9110 8.8.3.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KitchenSink" + } + } + } + }, + "304": { + "description": "Not Modified" + }, + "404": { + "description": "Not Found" + } + } + }, + "put": { + "tags": [ + "KitchenSink" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "If-Match", + "in": "header", + "description": "Conditionally execute only if the entity version matches the provided string (RFC 9110 13.1.1).", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "headers": { + "ETag": { + "description": "The opaque versioning identifier of the entity, per RFC 9110 8.8.3.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KitchenSink" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "Not Found" + }, + "410": { + "description": "Gone" + }, + "409": { + "description": "Conflict", + "headers": { + "ETag": { + "description": "The opaque versioning identifier of the conflicting entity, per RFC 9110 8.8.3.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KitchenSink" + } + } + } + }, + "412": { + "description": "Precondition failed", + "headers": { + "ETag": { + "description": "The opaque versioning identifier of the conflicting entity, per RFC 9110 8.8.3.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KitchenSink" + } + } + } + } + } + } + }, + "/tables/TodoItem": { + "post": { + "tags": [ + "TodoItem" + ], + "parameters": [ + { + "name": "If-None-Match", + "in": "header", + "description": "Conditionally execute only if the entity version does not match the provided string (RFC 9110 13.1.2).", + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "Created", + "headers": { + "ETag": { + "description": "The opaque versioning identifier of the entity, per RFC 9110 8.8.3.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoItem" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "409": { + "description": "Conflict", + "headers": { + "ETag": { + "description": "The opaque versioning identifier of the conflicting entity, per RFC 9110 8.8.3.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoItem" + } + } + } + }, + "412": { + "description": "Precondition failed", + "headers": { + "ETag": { + "description": "The opaque versioning identifier of the conflicting entity, per RFC 9110 8.8.3.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoItem" + } + } + } + } + } + }, + "get": { + "tags": [ + "TodoItem" + ], + "parameters": [ + { + "name": "$count", + "in": "query", + "description": "If true, return the total number of items matched by the filter", + "schema": { + "type": "boolean" + } + }, + { + "name": "$filter", + "in": "query", + "description": "An OData filter describing the entities to be returned", + "schema": { + "type": "string" + } + }, + { + "name": "$orderby", + "in": "query", + "description": "A comma-separated list of ordering instructions. Each ordering instruction is a field name with an optional direction (asc or desc).", + "schema": { + "type": "string" + } + }, + { + "name": "$select", + "in": "query", + "description": "A comma-separated list of fields to be returned in the result set.", + "schema": { + "type": "string" + } + }, + { + "name": "$skip", + "in": "query", + "description": "The number of items in the list to skip for paging support.", + "schema": { + "type": "integer" + } + }, + { + "name": "$top", + "in": "query", + "description": "The number of items in the list to return for paging support.", + "schema": { + "type": "integer" + } + }, + { + "name": "__includedeleted", + "in": "query", + "description": "If true, soft-deleted items are returned as well as non-deleted items.", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "OK", + "headers": { + "ETag": { + "description": "The opaque versioning identifier of the entity, per RFC 9110 8.8.3.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoItemPage" + } + } + } + }, + "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": "Conditionally execute only if the entity version matches the provided string (RFC 9110 13.1.1).", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "description": "Not Found" + }, + "410": { + "description": "Gone" + }, + "409": { + "description": "Conflict", + "headers": { + "ETag": { + "description": "The opaque versioning identifier of the conflicting entity, per RFC 9110 8.8.3.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoItem" + } + } + } + }, + "412": { + "description": "Precondition failed", + "headers": { + "ETag": { + "description": "The opaque versioning identifier of the conflicting entity, per RFC 9110 8.8.3.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoItem" + } + } + } + } + } + }, + "get": { + "tags": [ + "TodoItem" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "Conditionally execute only if the entity version does not match the provided string (RFC 9110 13.1.2).", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "headers": { + "ETag": { + "description": "The opaque versioning identifier of the entity, per RFC 9110 8.8.3.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoItem" + } + } + } + }, + "304": { + "description": "Not Modified" + }, + "404": { + "description": "Not Found" + } + } + }, + "put": { + "tags": [ + "TodoItem" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "If-Match", + "in": "header", + "description": "Conditionally execute only if the entity version matches the provided string (RFC 9110 13.1.1).", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "headers": { + "ETag": { + "description": "The opaque versioning identifier of the entity, per RFC 9110 8.8.3.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoItem" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "Not Found" + }, + "410": { + "description": "Gone" + }, + "409": { + "description": "Conflict", + "headers": { + "ETag": { + "description": "The opaque versioning identifier of the conflicting entity, per RFC 9110 8.8.3.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoItem" + } + } + } + }, + "412": { + "description": "Precondition failed", + "headers": { + "ETag": { + "description": "The opaque versioning identifier of the conflicting entity, per RFC 9110 8.8.3.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoItem" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "KitchenSink": { + "type": "object", + "properties": { + "id": { + "type": "string", + "nullable": true + }, + "deleted": { + "type": "boolean", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "readOnly": true + }, + "version": { + "type": "string", + "format": "byte", + "nullable": true, + "readOnly": true + }, + "booleanValue": { + "type": "boolean" + }, + "byteValue": { + "type": "integer", + "format": "int32" + }, + "byteArrayValue": { + "type": "string", + "format": "byte", + "nullable": true + }, + "charValue": { + "type": "string" + }, + "dateOnlyValue": { + "type": "string", + "format": "date" + }, + "dateTimeValue": { + "type": "string", + "format": "date-time" + }, + "dateTimeOffsetValue": { + "type": "string", + "format": "date-time" + }, + "decimalValue": { + "type": "number", + "format": "double" + }, + "doubleValue": { + "type": "number", + "format": "double" + }, + "enumValue": { + "$ref": "#/components/schemas/KitchenSinkState" + }, + "floatValue": { + "type": "number", + "format": "float" + }, + "guidValue": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "intValue": { + "type": "integer", + "format": "int32" + }, + "longValue": { + "type": "integer", + "format": "int64" + }, + "nullableDouble": { + "type": "number", + "format": "double", + "nullable": true + }, + "nullableEnumValue": { + "$ref": "#/components/schemas/KitchenSinkState" + }, + "stringValue": { + "type": "string", + "nullable": true + }, + "timeOnlyValue": { + "type": "string", + "format": "time" + } + }, + "additionalProperties": false + }, + "KitchenSinkPage": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KitchenSink" + }, + "nullable": true, + "readOnly": true + }, + "count": { + "type": "integer", + "format": "int64", + "nullable": true, + "readOnly": true + }, + "nextLink": { + "type": "string", + "format": "uri", + "nullable": true, + "readOnly": true + } + }, + "additionalProperties": false + }, + "KitchenSinkState": { + "enum": [ + 0, + 1, + 2 + ], + "type": "integer", + "format": "int32" + }, + "TodoItem": { + "type": "object", + "properties": { + "id": { + "type": "string", + "nullable": true + }, + "deleted": { + "type": "boolean", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "nullable": true, + "readOnly": true + }, + "version": { + "type": "string", + "format": "byte", + "nullable": true, + "readOnly": true + }, + "title": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "TodoItemPage": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TodoItem" + }, + "nullable": true, + "readOnly": true + }, + "count": { + "type": "integer", + "format": "int64", + "nullable": true, + "readOnly": true + }, + "nextLink": { + "type": "string", + "format": "uri", + "nullable": true, + "readOnly": true + } + }, + "additionalProperties": false + } + } + } +} \ No newline at end of file