diff --git a/sdk/dotnet/src/Microsoft.AspNetCore.Datasync.NSwag/DatasyncOperationProcessor.cs b/sdk/dotnet/src/Microsoft.AspNetCore.Datasync.NSwag/DatasyncOperationProcessor.cs index af8d9ead..c13ce3fd 100644 --- a/sdk/dotnet/src/Microsoft.AspNetCore.Datasync.NSwag/DatasyncOperationProcessor.cs +++ b/sdk/dotnet/src/Microsoft.AspNetCore.Datasync.NSwag/DatasyncOperationProcessor.cs @@ -42,8 +42,7 @@ private static void ProcessDatasyncOperation(OperationProcessorContext context) var path = context.OperationDescription.Path; Type entityType = context.ControllerType.BaseType?.GetGenericArguments().FirstOrDefault() ?? throw new ArgumentException("Cannot process a non-generic table controller"); - var entitySchema = context.SchemaResolver.GetSchema(entityType, false); - var entitySchemaRef = new JsonSchema { Reference = entitySchema }; + JsonSchema entitySchemaRef = GetEntityReference(context, entityType); operation.AddDatasyncRequestHeaders(); if (method.Equals("DELETE", StringComparison.InvariantCultureIgnoreCase)) @@ -94,6 +93,25 @@ private static void ProcessDatasyncOperation(OperationProcessorContext context) } } + /// + /// Either reads or generates the required entity type schema. + /// + /// The context for the operation processor. + /// The entity type needed. + /// A reference to the entity schema. + private static JsonSchema GetEntityReference(OperationProcessorContext context, Type entityType) + { + var schemaName = context.SchemaGenerator.Settings.SchemaNameGenerator.Generate(entityType); + if (!context.Document.Definitions.ContainsKey(schemaName)) + { + var newSchema = context.SchemaGenerator.Generate(entityType); + context.Document.Definitions.Add(schemaName, newSchema); + } + + var actualSchema = context.Document.Definitions[schemaName]; + return new JsonSchema { Reference = actualSchema }; + } + /// /// Creates the paged item schema reference. /// diff --git a/sdk/dotnet/test/Microsoft.AspNetCore.Datasync.NSwag.Test/NSwag_Tests.cs b/sdk/dotnet/test/Microsoft.AspNetCore.Datasync.NSwag.Test/NSwag_Tests.cs index 062b7a65..991d2893 100644 --- a/sdk/dotnet/test/Microsoft.AspNetCore.Datasync.NSwag.Test/NSwag_Tests.cs +++ b/sdk/dotnet/test/Microsoft.AspNetCore.Datasync.NSwag.Test/NSwag_Tests.cs @@ -16,14 +16,14 @@ private static string ReadExternalFile(string filename) { Assembly asm = Assembly.GetExecutingAssembly(); using Stream s = asm.GetManifestResourceStream(asm.GetName().Name + "." + filename)!; - using StreamReader sr = new StreamReader(s); + using StreamReader sr = new(s); return sr.ReadToEnd(); } private static void WriteExternalFile(string filename, string content) { var storePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - using StreamWriter outputFile = new StreamWriter(Path.Combine(storePath, filename)); + using StreamWriter outputFile = new(Path.Combine(storePath, filename)); outputFile.Write(content); } @@ -40,6 +40,6 @@ public async Task NSwag_GeneratesSwagger() { WriteExternalFile("swagger.json.out", actualContent); } - Assert.Equal(expectedContent, actualContent.Replace("\r\n", "\n").TrimEnd()); + Assert.Equal(expectedContent, actualContent); } } \ No newline at end of file diff --git a/sdk/dotnet/test/Microsoft.AspNetCore.Datasync.NSwag.Test/Service/KitchenReaderController.cs b/sdk/dotnet/test/Microsoft.AspNetCore.Datasync.NSwag.Test/Service/KitchenReaderController.cs new file mode 100644 index 00000000..eaedbb6a --- /dev/null +++ b/sdk/dotnet/test/Microsoft.AspNetCore.Datasync.NSwag.Test/Service/KitchenReaderController.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Datasync.EFCore; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Datasync.NSwag.Test.Service; + +[ExcludeFromCodeCoverage] +public abstract class ReadonlyTableController : TableController where TData : class, ITableData +{ + [NonAction] + public override Task CreateAsync([FromBody] TData item, CancellationToken token = default) + { + return base.CreateAsync(item, token); + } + + [NonAction] + public override Task DeleteAsync([FromRoute] string id, CancellationToken token = default) + { + return base.DeleteAsync(id, token); + } + + [NonAction] + public override Task PatchAsync([FromRoute] string id, CancellationToken token = default) + { + return base.PatchAsync(id, token); + } + + [NonAction] + public override Task ReplaceAsync([FromRoute] string id, [FromBody] TData item, CancellationToken token = default) + { + return base.ReplaceAsync(id, item, token); + } +} + +[Route("tables/kitchenreader")] +[ExcludeFromCodeCoverage] +public class KitchenReaderController : ReadonlyTableController +{ + public KitchenReaderController(ServiceDbContext context, ILogger logger) : base() + { + Repository = new EntityTableRepository(context); + Logger = logger; + } +} + diff --git a/sdk/dotnet/test/Microsoft.AspNetCore.Datasync.NSwag.Test/swagger.json b/sdk/dotnet/test/Microsoft.AspNetCore.Datasync.NSwag.Test/swagger.json index d85fd736..0bbce2a4 100644 --- a/sdk/dotnet/test/Microsoft.AspNetCore.Datasync.NSwag.Test/swagger.json +++ b/sdk/dotnet/test/Microsoft.AspNetCore.Datasync.NSwag.Test/swagger.json @@ -11,6 +11,224 @@ } ], "paths": { + "/tables/kitchenreader": { + "get": { + "tags": [ + "KitchenReader" + ], + "operationId": "KitchenReader_Query", + "parameters": [ + { + "name": "ZUMO-API-VERSION", + "in": "header", + "required": true, + "description": "Sets the Datasync API version to use.", + "schema": { + "type": "string", + "enum": [ + "3.0.0", + "2.0.0" + ] + } + }, + { + "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", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "A page of KitchenSink entities", + "properties": { + "items": { + "type": "array", + "readOnly": true, + "description": "The entities in this page of results", + "nullable": true, + "items": { + "$ref": "#/components/schemas/KitchenSink" + } + }, + "count": { + "type": "integer", + "readOnly": true, + "description": "The count of all entities in the result set", + "nullable": true + }, + "nextLink": { + "type": "string", + "readOnly": true, + "description": "The URI to the next page of entities", + "format": "uri", + "nullable": true + } + } + } + } + } + }, + "400": { + "description": "Bad Request" + } + } + } + }, + "/tables/kitchenreader/{id}": { + "get": { + "tags": [ + "KitchenReader" + ], + "operationId": "KitchenReader_Read", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "x-position": 1 + }, + { + "name": "ZUMO-API-VERSION", + "in": "header", + "required": true, + "description": "Sets the Datasync API version to use.", + "schema": { + "type": "string", + "enum": [ + "3.0.0", + "2.0.0" + ] + } + }, + { + "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 version string of the server entity, per RFC 9110", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KitchenSink" + } + } + } + }, + "409": { + "description": "Conflict", + "headers": { + "ETag": { + "description": "The version string of the server entity, per RFC 9110", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KitchenSink" + } + } + } + }, + "412": { + "description": "Precondition Failed", + "headers": { + "ETag": { + "description": "The version string of the server entity, per RFC 9110", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KitchenSink" + } + } + } + }, + "404": { + "description": "Not Found" + } + } + } + }, "/tables/kitchensink": { "post": { "tags": [ @@ -45,7 +263,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/KitchenSink" + "$ref": "#/components/schemas/KitchenSink2" } } }, @@ -558,7 +776,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/KitchenSink" + "$ref": "#/components/schemas/KitchenSink2" } } }, @@ -1257,6 +1475,93 @@ "components": { "schemas": { "KitchenSink": { + "title": "KitchenSink", + "definitions": { + "EntityTableData": { + "type": "object", + "x-abstract": true, + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "nullable": true + }, + "updatedAt": { + "type": "string", + "readOnly": true, + "format": "date-time" + }, + "version": { + "type": "string", + "readOnly": true, + "format": "byte", + "nullable": true + }, + "deleted": { + "type": "boolean", + "readOnly": true + } + } + } + }, + "allOf": [ + { + "$ref": "#/components/schemas/KitchenSink/definitions/EntityTableData" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "booleanValue": { + "type": "boolean" + }, + "intValue": { + "type": "integer", + "format": "int32" + }, + "longValue": { + "type": "integer", + "format": "int64" + }, + "decimalValue": { + "type": "number", + "format": "decimal" + }, + "doubleValue": { + "type": "number", + "format": "double" + }, + "floatValue": { + "type": "number", + "format": "float" + }, + "charValue": { + "type": "string" + }, + "stringValue": { + "type": "string", + "nullable": true + }, + "dateTimeValue": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "dateTimeOffsetValue": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "guidValue": { + "type": "string", + "format": "guid", + "nullable": true + } + } + } + ] + }, + "KitchenSink2": { "allOf": [ { "$ref": "#/components/schemas/EntityTableData" @@ -1359,4 +1664,4 @@ } } } -} \ No newline at end of file +} diff --git a/sdk/dotnet/test/Microsoft.AspNetCore.Datasync.Swashbuckle.Test/Service/KitchenReaderController.cs b/sdk/dotnet/test/Microsoft.AspNetCore.Datasync.Swashbuckle.Test/Service/KitchenReaderController.cs new file mode 100644 index 00000000..5c369253 --- /dev/null +++ b/sdk/dotnet/test/Microsoft.AspNetCore.Datasync.Swashbuckle.Test/Service/KitchenReaderController.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Datasync.EFCore; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Datasync.Swashbuckle.Test.Service +{ + [ExcludeFromCodeCoverage] + public abstract class ReadonlyTableController : TableController where TData : class, ITableData + { + [NonAction] + public override Task CreateAsync([FromBody] TData item, CancellationToken token = default) + { + return base.CreateAsync(item, token); + } + + [NonAction] + public override Task DeleteAsync([FromRoute] string id, CancellationToken token = default) + { + return base.DeleteAsync(id, token); + } + + [NonAction] + public override Task PatchAsync([FromRoute] string id, CancellationToken token = default) + { + return base.PatchAsync(id, token); + } + + [NonAction] + public override Task ReplaceAsync([FromRoute] string id, [FromBody] TData item, CancellationToken token = default) + { + return base.ReplaceAsync(id, item, token); + } + } + + [Route("tables/kitchenreader")] + [ExcludeFromCodeCoverage] + public class KitchenReaderController : ReadonlyTableController + { + public KitchenReaderController(ServiceDbContext context, ILogger logger) : base() + { + Repository = new EntityTableRepository(context); + Logger = logger; + } + } +} diff --git a/sdk/dotnet/test/Microsoft.AspNetCore.Datasync.Swashbuckle.Test/SwaggerGen_Tests.cs b/sdk/dotnet/test/Microsoft.AspNetCore.Datasync.Swashbuckle.Test/SwaggerGen_Tests.cs index 5ae9b290..aaa486ad 100644 --- a/sdk/dotnet/test/Microsoft.AspNetCore.Datasync.Swashbuckle.Test/SwaggerGen_Tests.cs +++ b/sdk/dotnet/test/Microsoft.AspNetCore.Datasync.Swashbuckle.Test/SwaggerGen_Tests.cs @@ -19,6 +19,13 @@ private static string ReadExternalFile(string filename) return sr.ReadToEnd(); } + private static void WriteExternalFile(string filename, string content) + { + var storePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + using StreamWriter outputFile = new(Path.Combine(storePath, filename)); + outputFile.Write(content); + } + [Fact] public void DocumentFilter_ReadsAllControllers() { @@ -27,7 +34,8 @@ public void DocumentFilter_ReadsAllControllers() var controllers = DatasyncDocumentFilter.GetAllTableControllers().Select(m => m.Name).ToList(); // There should be two controllers - Assert.Equal(2, controllers.Count); + Assert.Equal(3, controllers.Count); + Assert.Contains("KitchenReaderController", controllers); Assert.Contains("KitchenSinkController", controllers); Assert.Contains("TodoItemController", controllers); } @@ -40,7 +48,11 @@ public async Task SwaggerGen_GeneratesSwagger() Assert.True(swaggerDoc!.IsSuccessStatusCode); var expectedContent = ReadExternalFile("swagger.json").Replace("\r\n", "\n").TrimEnd(); - var actualContent = await swaggerDoc!.Content.ReadAsStringAsync(); - Assert.Equal(expectedContent, actualContent.Replace("\r\n", "\n").TrimEnd()); + var actualContent = (await swaggerDoc!.Content.ReadAsStringAsync()).Replace("\r\n", "\n").TrimEnd(); + if (!expectedContent.Equals(actualContent)) + { + WriteExternalFile("swagger.json.out", actualContent); + } + Assert.Equal(expectedContent, actualContent); } } diff --git a/sdk/dotnet/test/Microsoft.AspNetCore.Datasync.Swashbuckle.Test/swagger.json b/sdk/dotnet/test/Microsoft.AspNetCore.Datasync.Swashbuckle.Test/swagger.json index ec1f1b2d..2b189ff5 100644 --- a/sdk/dotnet/test/Microsoft.AspNetCore.Datasync.Swashbuckle.Test/swagger.json +++ b/sdk/dotnet/test/Microsoft.AspNetCore.Datasync.Swashbuckle.Test/swagger.json @@ -5,6 +5,40 @@ "version": "1.0" }, "paths": { + "/tables/kitchenreader": { + "get": { + "tags": [ + "KitchenReader" + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/tables/kitchenreader/{id}": { + "get": { + "tags": [ + "KitchenReader" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, "/tables/kitchensink": { "post": { "tags": [ @@ -1271,4 +1305,4 @@ } } } -} \ No newline at end of file +}