diff --git a/Directory.Packages.props b/Directory.Packages.props index 2d46957..af32d9a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,40 +6,40 @@ - + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + - + - + - + - + - + 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 f0c61a3..08e6be2 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,15 +1,15 @@  - net9.0 + net10.0 enable enable 2fc55b72-4090-46ad-ae44-8b6a415339b8 - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/CommunityToolkit.Datasync.Client/CommunityToolkit.Datasync.Client.csproj b/src/CommunityToolkit.Datasync.Client/CommunityToolkit.Datasync.Client.csproj index f483158..7f1a1f3 100644 --- a/src/CommunityToolkit.Datasync.Client/CommunityToolkit.Datasync.Client.csproj +++ b/src/CommunityToolkit.Datasync.Client/CommunityToolkit.Datasync.Client.csproj @@ -1,4 +1,4 @@ - + The client capabilities for developing applications using the Datasync Toolkit. @@ -12,7 +12,6 @@ - diff --git a/src/CommunityToolkit.Datasync.Server.OpenApi/DatasyncOperationTransformer.cs b/src/CommunityToolkit.Datasync.Server.OpenApi/DatasyncOperationTransformer.cs index f6d5ad6..671f866 100644 --- a/src/CommunityToolkit.Datasync.Server.OpenApi/DatasyncOperationTransformer.cs +++ b/src/CommunityToolkit.Datasync.Server.OpenApi/DatasyncOperationTransformer.cs @@ -6,7 +6,10 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.OpenApi; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; +using System.Diagnostics.CodeAnalysis; +using System.Reflection.Metadata; +using System.Text.Json.Nodes; namespace CommunityToolkit.Datasync.Server.OpenApi; @@ -61,6 +64,54 @@ public async Task TransformAsync(OpenApiOperation operation, OpenApiOperationTra return; } + internal async Task GetSchemaReferenceAsync(OpenApiOperationTransformerContext context, Type entityType) + { + if(context.Document is null) + { + throw new InvalidOperationException("The OpenAPI document is not available in the transformer context."); + } + + if (!this.processedEntityNames.Contains(entityType.Name)) + { + OpenApiSchema schema = await context.GetOrCreateSchemaAsync(entityType); + + // This is a Datasync schema, so update the schema for the datasync attributes. + schema.MakeSystemPropertiesReadonly(); + + _ = context.Document.AddComponent(entityType.Name, schema); + + Type pagedEntityType = typeof(Page<>).MakeGenericType(entityType); + string pagedEntitySchemaName = $"{entityType.Name}Page"; + + OpenApiSchema pagedEntitySchema = await context.GetOrCreateSchemaAsync(pagedEntityType); + + OpenApiSchema itemsProp = (OpenApiSchema)pagedEntitySchema.Properties!["items"]; + OpenApiSchema countProp = (OpenApiSchema)pagedEntitySchema.Properties!["count"]; + OpenApiSchema nextLinkProp = (OpenApiSchema)pagedEntitySchema.Properties!["nextLink"]; + itemsProp.ReadOnly = true; + countProp.ReadOnly = true; + //countProp.Type = JsonSchemaType.Integer; + //countProp.Format = "int64"; + //countProp.Pattern = null; + nextLinkProp.ReadOnly = true; + //nextLinkProp.Type = JsonSchemaType.String; + //nextLinkProp.Format = "uri"; + pagedEntitySchema.AdditionalPropertiesAllowed = false; + //_ = pagedEntitySchema.Required?.Remove("items"); + + itemsProp.Items = new OpenApiSchemaReference(entityType.Name, context.Document); + + _ = context.Document.AddComponent(pagedEntitySchemaName, pagedEntitySchema); + + this.processedEntityNames.Add(entityType.Name); + } + + OpenApiSchemaReference schemaRef = new(entityType.Name, context.Document); + + return schemaRef; + } + private readonly List processedEntityNames = []; + /// /// Determines if a controller presented is a datasync controller. /// @@ -103,21 +154,23 @@ internal static Type GetEntityType(OpenApiOperationTransformerContext context) /// 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) + internal async Task TransformCreateAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) { Type entityType = GetEntityType(context); - operation.AddRequestBody(context.GetSchemaForType(entityType)); + OpenApiSchemaReference schemaReference = await GetSchemaReferenceAsync(context, entityType); + + operation.AddRequestBody(schemaReference); + + operation.Responses ??= []; operation.Responses.AddEntityResponse(StatusCodes.Status201Created, - context.GetSchemaForType(entityType), includeConditionalHeaders: true); + schemaReference, includeConditionalHeaders: true); operation.Responses.AddStatusCode(StatusCodes.Status400BadRequest); operation.Responses.AddEntityResponse(StatusCodes.Status409Conflict, - context.GetSchemaForType(entityType), includeConditionalHeaders: true); + schemaReference, includeConditionalHeaders: true); operation.Responses.AddEntityResponse(StatusCodes.Status412PreconditionFailed, - context.GetSchemaForType(entityType), includeConditionalHeaders: true); - - return Task.CompletedTask; + schemaReference, includeConditionalHeaders: true); } /// @@ -127,22 +180,26 @@ internal Task TransformCreateAsync(OpenApiOperation operation, OpenApiOperationT /// 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) + internal async Task TransformDeleteAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) { Type entityType = GetEntityType(context); + OpenApiSchemaReference schemaReference = await GetSchemaReferenceAsync(context, entityType); + + operation.Parameters ??= []; + operation.Parameters.AddIfMatchHeader(); operation.Parameters.AddIfUnmodifiedSinceHeader(); + operation.Responses ??= []; + 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); + schemaReference, includeConditionalHeaders: true); operation.Responses.AddEntityResponse(StatusCodes.Status412PreconditionFailed, - context.GetSchemaForType(entityType), includeConditionalHeaders: true); - - return Task.CompletedTask; + schemaReference, includeConditionalHeaders: true); } /// @@ -152,10 +209,13 @@ internal Task TransformDeleteAsync(OpenApiOperation operation, OpenApiOperationT /// 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) + internal async Task TransformQueryAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) { Type entityType = GetEntityType(context); - Type pagedEntityType = typeof(PagedResult<>).MakeGenericType(entityType); + + OpenApiSchemaReference pagedEntitySchemaRef = new($"{entityType.Name}Page", context.Document); + + operation.Parameters ??= []; 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"); @@ -165,11 +225,11 @@ internal Task TransformQueryAsync(OpenApiOperation operation, OpenApiOperationTr operation.Parameters.AddIntQueryParameter("$top", "The number of items to return", 1); operation.Parameters.AddIncludeDeletedQuery(); + operation.Responses ??= []; + operation.Responses.AddEntityResponse(StatusCodes.Status200OK, - context.GetSchemaForType(pagedEntityType), includeConditionalHeaders: false); + pagedEntitySchemaRef, includeConditionalHeaders: false); operation.Responses.AddStatusCode(StatusCodes.Status400BadRequest); - - return Task.CompletedTask; } /// @@ -179,21 +239,25 @@ internal Task TransformQueryAsync(OpenApiOperation operation, OpenApiOperationTr /// 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) + internal async Task TransformReadAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) { Type entityType = GetEntityType(context); + OpenApiSchemaReference schemaReference = await GetSchemaReferenceAsync(context, entityType); + + operation.Parameters ??= []; + operation.Parameters.AddIncludeDeletedQuery(); operation.Parameters.AddIfNoneMatchHeader(); operation.Parameters.AddIfModifiedSinceHeader(); + operation.Responses ??= []; + operation.Responses.AddEntityResponse(StatusCodes.Status200OK, - context.GetSchemaForType(entityType), includeConditionalHeaders: true); + schemaReference, includeConditionalHeaders: true); operation.Responses.AddStatusCode(StatusCodes.Status304NotModified); operation.Responses.AddStatusCode(StatusCodes.Status404NotFound); operation.Responses.AddStatusCode(StatusCodes.Status410Gone); - - return Task.CompletedTask; } /// @@ -203,25 +267,52 @@ internal Task TransformReadAsync(OpenApiOperation operation, OpenApiOperationTra /// 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) + internal async Task TransformReplaceAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) { Type entityType = GetEntityType(context); - operation.AddRequestBody(context.GetSchemaForType(entityType)); + OpenApiSchemaReference schemaReference = await GetSchemaReferenceAsync(context, entityType); + + operation.Parameters ??= []; + + operation.AddRequestBody(schemaReference); operation.Parameters.AddIncludeDeletedQuery(); operation.Parameters.AddIfMatchHeader(); operation.Parameters.AddIfUnmodifiedSinceHeader(); + operation.Responses ??= []; + operation.Responses.AddEntityResponse(StatusCodes.Status200OK, - context.GetSchemaForType(entityType), includeConditionalHeaders: true); + schemaReference, 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); + schemaReference, includeConditionalHeaders: true); operation.Responses.AddEntityResponse(StatusCodes.Status412PreconditionFailed, - context.GetSchemaForType(entityType), includeConditionalHeaders: true); + schemaReference, includeConditionalHeaders: true); + } - return Task.CompletedTask; + /// + /// 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.OpenApi/InternalExtensions.cs b/src/CommunityToolkit.Datasync.Server.OpenApi/InternalExtensions.cs index 82ed11b..853604c 100644 --- a/src/CommunityToolkit.Datasync.Server.OpenApi/InternalExtensions.cs +++ b/src/CommunityToolkit.Datasync.Server.OpenApi/InternalExtensions.cs @@ -4,9 +4,9 @@ using Microsoft.AspNetCore.OpenApi; using Microsoft.AspNetCore.WebUtilities; -using Microsoft.OpenApi.Any; -using Microsoft.OpenApi.Models; using System.Net.Mime; +using Microsoft.OpenApi; +using System.Text.Json.Nodes; namespace CommunityToolkit.Datasync.Server.OpenApi; @@ -20,15 +20,17 @@ internal static class InternalExtensions /// /// The operation to modify. /// The schema for the entity in the body. - internal static void AddRequestBody(this OpenApiOperation operation, OpenApiSchema bodySchema) + internal static void AddRequestBody(this OpenApiOperation operation, IOpenApiSchema bodySchema) { - operation.RequestBody ??= new OpenApiRequestBody(); - operation.RequestBody.Content.Add(MediaTypeNames.Application.Json, new OpenApiMediaType + OpenApiRequestBody requestBody = new(); + requestBody.Content ??= new Dictionary(); + requestBody.Content.Add(MediaTypeNames.Application.Json, new OpenApiMediaType { Schema = bodySchema }); - operation.RequestBody.Description = "The entity to process."; - operation.RequestBody.Required = true; + requestBody.Description = "The entity to process."; + requestBody.Required = true; + operation.RequestBody ??= requestBody; } /// @@ -37,7 +39,7 @@ internal static void AddRequestBody(this OpenApiOperation operation, OpenApiSche /// The parameters collection. /// The parameter name. /// The parameter description. - internal static void AddBooleanQueryParameter(this IList parameters, string paramName, string description) + internal static void AddBooleanQueryParameter(this IList parameters, string paramName, string description) { parameters.Add(new OpenApiParameter { @@ -47,8 +49,7 @@ internal static void AddBooleanQueryParameter(this IList param Required = false, Schema = new OpenApiSchema { - Type = "string", - Enum = [new OpenApiString("true"), new OpenApiString("false")] + Type = JsonSchemaType.Boolean } }); } @@ -59,7 +60,7 @@ internal static void AddBooleanQueryParameter(this IList param /// The parameters collection. /// The parameter name. /// The parameter description. - internal static void AddDateTimeHeader(this IList parameters, string headerName, string description) + internal static void AddDateTimeHeader(this IList parameters, string headerName, string description) { parameters.Add(new OpenApiParameter { @@ -67,7 +68,7 @@ internal static void AddDateTimeHeader(this IList parameters, In = ParameterLocation.Header, Description = description, Required = false, - Schema = new OpenApiSchema { Type = "string", Format = "date-time" } + Schema = new OpenApiSchema { Type = JsonSchemaType.String, Format = "date-time" } }); } @@ -79,7 +80,7 @@ internal static void AddDateTimeHeader(this IList parameters, /// 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) + int statusCode, IOpenApiSchema schema, bool includeConditionalHeaders = true) { OpenApiResponse response = new() { @@ -91,17 +92,17 @@ internal static void AddEntityResponse(this OpenApiResponses responses, Schema = schema } }, - Headers = includeConditionalHeaders ? new Dictionary + Headers = includeConditionalHeaders ? new Dictionary { ["ETag"] = new OpenApiHeader { Description = "The ETag value for the entity", - Schema = new OpenApiSchema { Type = "string" } + Schema = new OpenApiSchema { Type = JsonSchemaType.String } }, ["Last-Modified"] = new OpenApiHeader { Description = "The last modified timestamp for the entity", - Schema = new OpenApiSchema { Type = "string", Format = "date-time" } + Schema = new OpenApiSchema { Type = JsonSchemaType.String, Format = "date-time" } } } : null }; @@ -112,35 +113,35 @@ internal static void AddEntityResponse(this OpenApiResponses responses, /// Adds the __includeDeleted query parameter to the operation. /// /// The parameters list to modify. - internal static void AddIncludeDeletedQuery(this IList parameters) + 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) + 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) + 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) + 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) + internal static void AddIfMatchHeader(this IList parameters) => parameters.AddStringHeader("If-Match", "ETag value to conditionally fetch the entity"); /// @@ -150,7 +151,7 @@ internal static void AddIfMatchHeader(this IList parameters) /// 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) + internal static void AddIntQueryParameter(this IList parameters, string paramName, string description, int minValue = 0) { parameters.Add(new OpenApiParameter { @@ -158,7 +159,7 @@ internal static void AddIntQueryParameter(this IList parameter In = ParameterLocation.Query, Description = description, Required = false, - Schema = new OpenApiSchema { Type = "integer", Minimum = minValue } + Schema = new OpenApiSchema { Type = JsonSchemaType.Integer, Minimum = minValue.ToString() } }); } @@ -176,7 +177,7 @@ internal static void AddStatusCode(this OpenApiResponses responses, int statusCo /// The parameters collection. /// The parameter name. /// The parameter description. - internal static void AddStringHeader(this IList parameters, string headerName, string description) + internal static void AddStringHeader(this IList parameters, string headerName, string description) { parameters.Add(new OpenApiParameter { @@ -184,7 +185,7 @@ internal static void AddStringHeader(this IList parameters, st In = ParameterLocation.Header, Description = description, Required = false, - Schema = new OpenApiSchema { Type = "string" } + Schema = new OpenApiSchema { Type = JsonSchemaType.String } }); } @@ -194,7 +195,7 @@ internal static void AddStringHeader(this IList parameters, st /// The parameters collection. /// The parameter name. /// The parameter description. - internal static void AddStringQueryParameter(this IList parameters, string paramName, string description) + internal static void AddStringQueryParameter(this IList parameters, string paramName, string description) { parameters.Add(new OpenApiParameter { @@ -202,22 +203,20 @@ internal static void AddStringQueryParameter(this IList parame In = ParameterLocation.Query, Description = description, Required = false, - Schema = new OpenApiSchema { Type = "string" } + Schema = new OpenApiSchema { Type = JsonSchemaType.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) + private static readonly string[] SystemProperties = ["updatedAt", "version", "deleted"]; + internal static void MakeSystemPropertiesReadonly(this OpenApiSchema schema) { - // TODO: Add support for retrieving schemas. - return new OpenApiSchema { Type = "object" }; + foreach (KeyValuePair property in schema.Properties ?? new Dictionary()) + { + if (SystemProperties.Contains(property.Key) && property.Value is OpenApiSchema prop) + { + prop.ReadOnly = true; + } + } } /// diff --git a/src/CommunityToolkit.Datasync.Server.Swashbuckle/DatasyncDocumentFilter.cs b/src/CommunityToolkit.Datasync.Server.Swashbuckle/DatasyncDocumentFilter.cs index 0d4f956..66f7b9a 100644 --- a/src/CommunityToolkit.Datasync.Server.Swashbuckle/DatasyncDocumentFilter.cs +++ b/src/CommunityToolkit.Datasync.Server.Swashbuckle/DatasyncDocumentFilter.cs @@ -4,7 +4,7 @@ using CommunityToolkit.Datasync.Server.Filters; using Microsoft.AspNetCore.Mvc.ApiExplorer; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; using Swashbuckle.AspNetCore.SwaggerGen; using System.Diagnostics.CodeAnalysis; using System.Reflection; @@ -74,11 +74,11 @@ internal void ProcessController(Type entityType, string routePath, OpenApiDocume // 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); + AddOperationIfPresent(operations, OpType.Create, document, allEntitiesPath, HttpMethod.Post); + AddOperationIfPresent(operations, OpType.Delete, document, singleEntityPath, HttpMethod.Delete); + AddOperationIfPresent(operations, OpType.GetById, document, singleEntityPath, HttpMethod.Get); + AddOperationIfPresent(operations, OpType.List, document, allEntitiesPath, HttpMethod.Get); + AddOperationIfPresent(operations, OpType.Replace, document, singleEntityPath, HttpMethod.Put); // Make the system properties in the entity read-only if (!this.processedEntityNames.Contains(entityType.Name)) @@ -91,33 +91,37 @@ internal void ProcessController(Type entityType, string routePath, OpenApiDocume // 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].UnresolvedReference = false; context.SchemaRepository.Schemas[entityType.Name].Reference = new OpenApiReference { Id = entityType.Name, Type = ReferenceType.Schema - }; + };*/ + _ = document.AddComponent(entityType.Name, context.SchemaRepository.Schemas[entityType.Name]); this.processedEntityNames.Add(entityType.Name); } + IOpenApiSchema schema = new OpenApiSchemaReference(entityType.Name, document); + Type listEntityType = typeof(Page<>).MakeGenericType(entityType); - OpenApiSchema listSchemaRef = context.SchemaRepository.Schemas.GetValueOrDefault(listEntityType.Name) + IOpenApiSchema listSchemaRef = context.SchemaRepository.Schemas.GetValueOrDefault(listEntityType.Name) ?? context.SchemaGenerator.GenerateSchema(listEntityType, context.SchemaRepository); foreach (KeyValuePair operation in operations) { + operation.Value.Responses ??= new OpenApiResponses(); // Each operation also has certain modifications. switch (operation.Key) { case OpType.Create: // Request Edits operation.Value.AddConditionalHeader(true); - operation.Value.AddRequestWithContent(context.SchemaRepository.Schemas[entityType.Name]); + operation.Value.AddRequestWithContent(schema); // Response Edits - operation.Value.AddResponseWithContent("201", "Created", context.SchemaRepository.Schemas[entityType.Name]); + operation.Value.AddResponseWithContent("201", "Created", schema); operation.Value.Responses["400"] = new OpenApiResponse { Description = "Bad Request" }; - operation.Value.AddConflictResponse(context.SchemaRepository.Schemas[entityType.Name]); + operation.Value.AddConflictResponse(schema); break; case OpType.Delete: @@ -128,7 +132,7 @@ internal void ProcessController(Type entityType, string routePath, OpenApiDocume 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]); + operation.Value.AddConflictResponse(schema); break; case OpType.GetById: @@ -136,7 +140,7 @@ internal void ProcessController(Type entityType, string routePath, OpenApiDocume operation.Value.AddConditionalHeader(true); // Response Edits - operation.Value.AddResponseWithContent("200", "OK", context.SchemaRepository.Schemas[entityType.Name]); + operation.Value.AddResponseWithContent("200", "OK", schema); operation.Value.Responses["304"] = new OpenApiResponse { Description = "Not Modified" }; operation.Value.Responses["404"] = new OpenApiResponse { Description = "Not Found" }; break; @@ -153,14 +157,14 @@ internal void ProcessController(Type entityType, string routePath, OpenApiDocume case OpType.Replace: // Request Edits operation.Value.AddConditionalHeader(); - operation.Value.AddRequestWithContent(context.SchemaRepository.Schemas[entityType.Name]); + operation.Value.AddRequestWithContent(schema); // Response Edits - operation.Value.AddResponseWithContent("200", "OK", context.SchemaRepository.Schemas[entityType.Name]); + operation.Value.AddResponseWithContent("200", "OK", schema); 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]); + operation.Value.AddConflictResponse(schema); break; } } @@ -179,11 +183,11 @@ internal void ProcessController(Type entityType, string routePath, OpenApiDocume /// 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) + private static void AddOperationIfPresent(Dictionary operations, OpType opType, OpenApiDocument document, string path, HttpMethod operationType) { - if (document.Paths.TryGetValue(path, out OpenApiPathItem? pathValue)) + if (document.Paths.TryGetValue(path, out IOpenApiPathItem? pathValue)) { - if (pathValue!.Operations.TryGetValue(operationType, out OpenApiOperation? operation)) + if (pathValue.Operations!.TryGetValue(operationType, out OpenApiOperation? operation)) { operations[opType] = operation!; } diff --git a/src/CommunityToolkit.Datasync.Server.Swashbuckle/DatasyncOperationExtensions.cs b/src/CommunityToolkit.Datasync.Server.Swashbuckle/DatasyncOperationExtensions.cs index caed044..e6a8f10 100644 --- a/src/CommunityToolkit.Datasync.Server.Swashbuckle/DatasyncOperationExtensions.cs +++ b/src/CommunityToolkit.Datasync.Server.Swashbuckle/DatasyncOperationExtensions.cs @@ -2,7 +2,7 @@ // 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; +using Microsoft.OpenApi; namespace CommunityToolkit.Datasync.Server.Swashbuckle; @@ -24,13 +24,15 @@ internal static void AddConditionalHeader(this OpenApiOperation operation, bool ? "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 ??= new List(); + operation.Parameters.Add(new OpenApiParameter { Name = headerName, Description = description, In = ParameterLocation.Header, Required = false, - Schema = new OpenApiSchema { Type = "string" } + Schema = new OpenApiSchema { Type = JsonSchemaType.String } }); } @@ -41,8 +43,10 @@ internal static void AddConditionalHeader(this OpenApiOperation operation, bool /// 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) + internal static void AddODataQueryParameter(this OpenApiOperation operation, string parameterName, JsonSchemaType parameterType, string description) { + operation.Parameters ??= new List(); + operation.Parameters.Add(new OpenApiParameter { Name = parameterName, @@ -59,13 +63,13 @@ internal static void AddODataQueryParameter(this OpenApiOperation operation, str /// 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."); + operation.AddODataQueryParameter("$count", JsonSchemaType.Boolean, "If true, return the total number of items matched by the filter"); + operation.AddODataQueryParameter("$filter", JsonSchemaType.String, "An OData filter describing the entities to be returned"); + operation.AddODataQueryParameter("$orderby", JsonSchemaType.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", JsonSchemaType.String, "A comma-separated list of fields to be returned in the result set."); + operation.AddODataQueryParameter("$skip", JsonSchemaType.Integer, "The number of items in the list to skip for paging support."); + operation.AddODataQueryParameter("$top", JsonSchemaType.Integer, "The number of items in the list to return for paging support."); + operation.AddODataQueryParameter("__includedeleted", JsonSchemaType.Boolean, "If true, soft-deleted items are returned as well as non-deleted items."); } /// @@ -75,7 +79,7 @@ internal static void AddODataQueryParameters(this OpenApiOperation operation) /// 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) + internal static void AddResponseWithContent(this OpenApiOperation operation, string statusCode, string description, IOpenApiSchema schema) { OpenApiResponse response = new() { @@ -88,11 +92,15 @@ internal static void AddResponseWithContent(this OpenApiOperation operation, str string etagDescription = statusCode is "409" or "412" ? "The opaque versioning identifier of the conflicting entity" : "The opaque versioning identifier of the entity"; + response.Headers ??= new Dictionary(); + response.Headers.Add("ETag", new OpenApiHeader { - Schema = new OpenApiSchema { Type = "string" }, + Schema = new OpenApiSchema { Type = JsonSchemaType.String }, Description = $"{etagDescription}, per RFC 9110 8.8.3." }); + operation.Responses ??= new OpenApiResponses(); + operation.Responses[statusCode] = response; } /// @@ -100,7 +108,7 @@ internal static void AddResponseWithContent(this OpenApiOperation operation, str /// /// The to modify. /// The schema of the entity in the request. - internal static void AddRequestWithContent(this OpenApiOperation operation, OpenApiSchema schema) + internal static void AddRequestWithContent(this OpenApiOperation operation, IOpenApiSchema schema) { operation.RequestBody = new OpenApiRequestBody { @@ -119,7 +127,7 @@ internal static void AddRequestWithContent(this OpenApiOperation operation, Open /// /// The to modify. /// The schema of the entity to return. - internal static void AddConflictResponse(this OpenApiOperation operation, OpenApiSchema schema) + internal static void AddConflictResponse(this OpenApiOperation operation, IOpenApiSchema schema) { operation.AddResponseWithContent("409", "Conflict", schema); operation.AddResponseWithContent("412", "Precondition failed", schema); @@ -129,13 +137,13 @@ internal static void AddConflictResponse(this OpenApiOperation operation, OpenAp /// Makes the system properties in the schema read-only. /// /// The to edit. - public static void MakeSystemPropertiesReadonly(this OpenApiSchema schema) + public static void MakeSystemPropertiesReadonly(this IOpenApiSchema schema) { - foreach (KeyValuePair property in schema.Properties) + foreach (KeyValuePair property in schema.Properties ?? new Dictionary()) { - if (SystemProperties.Contains(property.Key)) + if (SystemProperties.Contains(property.Key) && property.Value is OpenApiSchema prop) { - property.Value.ReadOnly = true; + prop.ReadOnly = true; } } } diff --git a/src/Directory.Build.props b/src/Directory.Build.props index bc4bfd7..29473b7 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -26,7 +26,7 @@ 13.0 en enable - net9.0 + net10.0 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 index 30d0eb2..71abc93 100644 --- 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 @@ -1,4 +1,8 @@ - + + + $(InterceptorsNamespaces);Microsoft.AspNetCore.OpenApi.Generated + + diff --git a/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/openapi.json b/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/openapi.json index 709ef71..9377749 100644 --- a/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/openapi.json +++ b/tests/CommunityToolkit.Datasync.Server.OpenApi.Test/openapi.json @@ -1,5 +1,5 @@ { - "openapi": "3.0.1", + "openapi": "3.1.1", "info": { "title": "CommunityToolkit.Datasync.Server.OpenApi.Test | v1", "version": "1.0.0" @@ -1193,7 +1193,6 @@ } } }, - "components": { }, "tags": [ { "name": "KitchenReader" diff --git a/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/swagger.json b/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/swagger.json index f5757d8..ab6c226 100644 --- a/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/swagger.json +++ b/tests/CommunityToolkit.Datasync.Server.Swashbuckle.Test/swagger.json @@ -1114,5 +1114,16 @@ "additionalProperties": false } } - } + }, + "tags": [ + { + "name": "KitchenReader" + }, + { + "name": "KitchenSink" + }, + { + "name": "TodoItem" + } + ] } \ No newline at end of file diff --git a/tests/CommunityToolkit.Datasync.TestCommon/CommunityToolkit.Datasync.TestCommon.csproj b/tests/CommunityToolkit.Datasync.TestCommon/CommunityToolkit.Datasync.TestCommon.csproj index cf2bdbc..56b4197 100644 --- a/tests/CommunityToolkit.Datasync.TestCommon/CommunityToolkit.Datasync.TestCommon.csproj +++ b/tests/CommunityToolkit.Datasync.TestCommon/CommunityToolkit.Datasync.TestCommon.csproj @@ -19,7 +19,6 @@ - diff --git a/tests/CommunityToolkit.Datasync.TestCommon/Databases/MySQL/MysqlDbContext.cs b/tests/CommunityToolkit.Datasync.TestCommon/Databases/MySQL/MysqlDbContext.cs index df951d2..87c904f 100644 --- a/tests/CommunityToolkit.Datasync.TestCommon/Databases/MySQL/MysqlDbContext.cs +++ b/tests/CommunityToolkit.Datasync.TestCommon/Databases/MySQL/MysqlDbContext.cs @@ -18,7 +18,7 @@ public static async Task CreateContextAsync(string connectionStr } DbContextOptionsBuilder optionsBuilder = new DbContextOptionsBuilder() - .UseMySql(connectionString: connectionString, serverVersion: ServerVersion.AutoDetect(connectionString), options => options.EnableRetryOnFailure()) + //.UseMySql(connectionString: connectionString, serverVersion: ServerVersion.AutoDetect(connectionString), options => options.EnableRetryOnFailure()) .EnableLogging(output); MysqlDbContext context = new(optionsBuilder.Options); diff --git a/tests/CommunityToolkit.Datasync.TestService/CommunityToolkit.Datasync.TestService.csproj b/tests/CommunityToolkit.Datasync.TestService/CommunityToolkit.Datasync.TestService.csproj index d3a5262..c6654c9 100644 --- a/tests/CommunityToolkit.Datasync.TestService/CommunityToolkit.Datasync.TestService.csproj +++ b/tests/CommunityToolkit.Datasync.TestService/CommunityToolkit.Datasync.TestService.csproj @@ -1,4 +1,4 @@ - + false @@ -17,8 +17,6 @@ - - diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index 3ccbcf9..56133d1 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -26,7 +26,7 @@ 13.0 en $(NoWarn);7022 - net9.0 + net10.0