From ec15ee803c09f249e0d63314818cd3722d6f95a2 Mon Sep 17 00:00:00 2001 From: Sean Leonard Date: Wed, 12 Apr 2023 09:39:13 -0700 Subject: [PATCH 01/30] Add openAPI package --- src/Directory.Packages.props | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index ca85a0bf3e..56dd1ef7ed 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -17,13 +17,14 @@ - + + @@ -38,4 +39,4 @@ - + \ No newline at end of file From 6773bdda6838e053e24545ac741c8b2db613616f Mon Sep 17 00:00:00 2001 From: Sean Leonard Date: Thu, 13 Apr 2023 13:55:19 -0700 Subject: [PATCH 02/30] Initial openapi service, controller, and mapped swagger ui viewer endpoint. --- src/Config/DataApiBuilderException.cs | 6 +- src/Directory.Packages.props | 2 + .../Azure.DataApiBuilder.Service.csproj | 3 + src/Service/Controllers/OpenApiController.cs | 60 ++++++ src/Service/Services/IOpenApiDocumentor.cs | 13 ++ src/Service/Services/OpenApiDocumentor.cs | 182 ++++++++++++++++++ src/Service/Startup.cs | 9 + 7 files changed, 274 insertions(+), 1 deletion(-) create mode 100644 src/Service/Controllers/OpenApiController.cs create mode 100644 src/Service/Services/IOpenApiDocumentor.cs create mode 100644 src/Service/Services/OpenApiDocumentor.cs diff --git a/src/Config/DataApiBuilderException.cs b/src/Config/DataApiBuilderException.cs index da80b76381..fa4bf057b8 100644 --- a/src/Config/DataApiBuilderException.cs +++ b/src/Config/DataApiBuilderException.cs @@ -79,7 +79,11 @@ public enum SubStatusCodes /// /// Error encountered while doing data type conversions. /// - ErrorProcessingData + ErrorProcessingData, + /// + /// Attempting to generate OpenAPI document when one already exists. + /// + OpenApiDocumentAlreadyExists } public HttpStatusCode StatusCode { get; } diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 56dd1ef7ed..fbaf8e2383 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -33,6 +33,8 @@ + + diff --git a/src/Service/Azure.DataApiBuilder.Service.csproj b/src/Service/Azure.DataApiBuilder.Service.csproj index c7fcce75f8..ac5e652154 100644 --- a/src/Service/Azure.DataApiBuilder.Service.csproj +++ b/src/Service/Azure.DataApiBuilder.Service.csproj @@ -66,6 +66,7 @@ + @@ -75,6 +76,8 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + + diff --git a/src/Service/Controllers/OpenApiController.cs b/src/Service/Controllers/OpenApiController.cs new file mode 100644 index 0000000000..27cd241f27 --- /dev/null +++ b/src/Service/Controllers/OpenApiController.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Net; +using Azure.DataApiBuilder.Service.Services; +using Microsoft.AspNetCore.Mvc; + +namespace Azure.DataApiBuilder.Service.Controllers +{ + /// + /// + /// + [Route("api/[controller]")] + [ApiController] + public class OpenApiController : ControllerBase + { + private IOpenApiDocumentor _apiDocumentor; + + public OpenApiController(IOpenApiDocumentor openApiDocumentor) + { + _apiDocumentor = openApiDocumentor; + Console.WriteLine("api controller constructor"); + } + + /// + /// + /// + /// + [HttpGet] + public IActionResult Get() + { + return _apiDocumentor.TryGetDocument(out string? document) ? Ok(document) : NotFound(); + } + + /// + /// + /// + /// + [HttpPost] + public IActionResult Post() + { + try + { + _apiDocumentor.CreateDocument(); + + if (_apiDocumentor.TryGetDocument(out string? document)) + { + return new CreatedResult(location:"/openapi" ,value: document); + } + + return NotFound(); + } + catch (Exception) + { + return new StatusCodeResult(statusCode: (int)HttpStatusCode.InternalServerError); + } + } + } +} diff --git a/src/Service/Services/IOpenApiDocumentor.cs b/src/Service/Services/IOpenApiDocumentor.cs new file mode 100644 index 0000000000..8f670d11fd --- /dev/null +++ b/src/Service/Services/IOpenApiDocumentor.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; + +namespace Azure.DataApiBuilder.Service.Services +{ + public interface IOpenApiDocumentor + { + public bool TryGetDocument([NotNullWhen(true)] out string? document); + public void CreateDocument(); + } +} diff --git a/src/Service/Services/OpenApiDocumentor.cs b/src/Service/Services/OpenApiDocumentor.cs new file mode 100644 index 0000000000..c354802e41 --- /dev/null +++ b/src/Service/Services/OpenApiDocumentor.cs @@ -0,0 +1,182 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.IO; +using Azure.DataApiBuilder.Auth; +using Azure.DataApiBuilder.Service.Exceptions; +using Microsoft.OpenApi.Models; +using Microsoft.OpenApi.Writers; + +namespace Azure.DataApiBuilder.Service.Services +{ + public class OpenApiDocumentor : IOpenApiDocumentor + { + private ISqlMetadataProvider _metadataProvider; + private IAuthorizationResolver _authorizationResolver; + private OpenApiDocument? _openApiDocument; + + public OpenApiDocumentor(ISqlMetadataProvider sqlMetadataProvider, IAuthorizationResolver authorizationResolver) + { + _metadataProvider = sqlMetadataProvider; + _authorizationResolver = authorizationResolver; + _openApiDocument = null; + } + + /// + /// + /// + /// + /// + public bool TryGetDocument([NotNullWhen(true)] out string? document) + { + if (_openApiDocument is null) + { + document = null; + return false; + } + + using (StringWriter textWriter = new(CultureInfo.InvariantCulture)) + { + OpenApiJsonWriter jsonWriter = new(textWriter); + _openApiDocument.SerializeAsV3(jsonWriter); + + string jsonPayload = textWriter.ToString(); + document = jsonPayload; + return true; + } + } + + public void CreateDocument() + { + if (_openApiDocument is not null) + { + throw new DataApiBuilderException( + message: "already created", + statusCode: System.Net.HttpStatusCode.Conflict, + subStatusCode: DataApiBuilderException.SubStatusCodes.OpenApiDocumentAlreadyExists); + } + + OpenApiDocument doc = new() + { + Info = new OpenApiInfo + { + Version = "0.0.1", + Title = "Data API builder - OpenAPI Description Document", + }, + Servers = new List + { + new OpenApiServer { Url = "https://localhost:5000/api/openapi" } + }, + Paths = new OpenApiPaths + { + ["/Book/id/{id}"] = new OpenApiPathItem + { + Operations = new Dictionary + { + [OperationType.Get] = new OpenApiOperation + { + Description = "Returns all pets from the system that the user has access to", + Responses = new OpenApiResponses + { + ["200"] = new OpenApiResponse + { + Description = "OK", + Content = new Dictionary() + { + { + "application/json", + new OpenApiMediaType() + { + Schema = new OpenApiSchema() + { + Properties = new Dictionary() + { + { + "value", + new OpenApiSchema() + { + Type = "array", + Items = new OpenApiSchema() + { + Reference = new OpenApiReference() + { + Type = ReferenceType.Schema, + Id = "BookResponse" + } + } + } + } + } + } + } + } + } + + } + } + } + }, + Parameters = new List() + { + new OpenApiParameter() + { + Required = true, + In = ParameterLocation.Path, + Name = "id", + Schema = new OpenApiSchema() + { + Type = "integer", + Format = "int32" + } + } + } + } + }, + Components = new OpenApiComponents() + { + Schemas = new Dictionary() + { + { + "BookResponse", + new OpenApiSchema() + { + Type = "object", + Properties = new Dictionary() + { + { + "id", + new OpenApiSchema() + { + Type = "integer", + Format = "int32" + } + }, + { + "title", + new OpenApiSchema() + { + Type = "string" + } + }, + { + "publisher_id", + new OpenApiSchema() + { + Type = "integer", + Format = "int32" + } + } + } + } + } + } + + } + }; + _openApiDocument = doc; + } + } +} diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index c737e73e10..136d01032c 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -249,6 +249,7 @@ public void ConfigureServices(IServiceCollection services) }); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); AddGraphQL(services); @@ -355,6 +356,14 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, RuntimeC app.UseHttpsRedirection(); } + app.UseStaticFiles(); + //app.UseSwagger(); + app.UseSwaggerUI(c => + { + c.SwaggerEndpoint("/api/openapi", "DataAPIbuilder-OpenAPI-Alpha"); + } + ); + // URL Rewrite middleware MUST be called prior to UseRouting(). // https://andrewlock.net/understanding-pathbase-in-aspnetcore/#placing-usepathbase-in-the-correct-location app.UseCorrelationIdMiddleware(); From 33dafd35317f1ec54a033c4c5c68c213cd5af467 Mon Sep 17 00:00:00 2001 From: Sean Leonard Date: Fri, 14 Apr 2023 09:11:34 -0700 Subject: [PATCH 03/30] Add runtimeconfig to openapidocumentor. --- src/Service/Services/OpenApiDocumentor.cs | 43 ++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/src/Service/Services/OpenApiDocumentor.cs b/src/Service/Services/OpenApiDocumentor.cs index c354802e41..4ccbc6c189 100644 --- a/src/Service/Services/OpenApiDocumentor.cs +++ b/src/Service/Services/OpenApiDocumentor.cs @@ -6,6 +6,8 @@ using System.Globalization; using System.IO; using Azure.DataApiBuilder.Auth; +using Azure.DataApiBuilder.Config; +using Azure.DataApiBuilder.Service.Configurations; using Azure.DataApiBuilder.Service.Exceptions; using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Writers; @@ -17,12 +19,14 @@ public class OpenApiDocumentor : IOpenApiDocumentor private ISqlMetadataProvider _metadataProvider; private IAuthorizationResolver _authorizationResolver; private OpenApiDocument? _openApiDocument; + private RuntimeConfig _runtimeConfig; - public OpenApiDocumentor(ISqlMetadataProvider sqlMetadataProvider, IAuthorizationResolver authorizationResolver) + public OpenApiDocumentor(ISqlMetadataProvider sqlMetadataProvider, IAuthorizationResolver authorizationResolver, RuntimeConfigProvider runtimeConfigProvider) { _metadataProvider = sqlMetadataProvider; _authorizationResolver = authorizationResolver; _openApiDocument = null; + _runtimeConfig = runtimeConfigProvider.GetRuntimeConfiguration(); } /// @@ -178,5 +182,42 @@ public void CreateDocument() }; _openApiDocument = doc; } + + public Dictionary BuildComponents() + { + Dictionary components = new(); + Dictionary entityMetadata = _metadataProvider.EntityToDatabaseObject; + return components; + } + + /// + /// Returns schema object representing an Entity's non-primary key fields. + /// + /// + public OpenApiSchema EntityToSchemaObject() + { + return new OpenApiSchema(); + } + + /// + /// Returns schema object representing Entity including primary key and non-primary key fields. + /// + /// + public OpenApiSchema FullEntityToSchemaObject() + { + return new OpenApiSchema(); + } + + /// + /// Returns collection representing an entity's field metadata + /// Key: field name + /// Value: OpenApiSchema describing the (value) Type and (value) Format of the field. + /// + /// + public Dictionary BuildComponentProperties() + { + Dictionary fieldMetadata = new(); + return fieldMetadata; + } } } From 55a78e16c315dbe4a560084b016464e2e5d35035 Mon Sep 17 00:00:00 2001 From: Sean Leonard Date: Fri, 14 Apr 2023 14:59:47 -0700 Subject: [PATCH 04/30] Automate Component Schema creation for Full Entity (with primary key). --- src/Service/Services/OpenApiDocumentor.cs | 132 +++++++++++++++------- 1 file changed, 90 insertions(+), 42 deletions(-) diff --git a/src/Service/Services/OpenApiDocumentor.cs b/src/Service/Services/OpenApiDocumentor.cs index 4ccbc6c189..1e5e8e46e7 100644 --- a/src/Service/Services/OpenApiDocumentor.cs +++ b/src/Service/Services/OpenApiDocumentor.cs @@ -1,10 +1,14 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; +using System.Linq; +using System.Net; +using System.Text.Json; using Azure.DataApiBuilder.Auth; using Azure.DataApiBuilder.Config; using Azure.DataApiBuilder.Service.Configurations; @@ -139,55 +143,99 @@ public void CreateDocument() } } }, - Components = new OpenApiComponents() + Components = BuildComponents() + }; + _openApiDocument = doc; + } + + public OpenApiComponents BuildComponents() + { + return new OpenApiComponents() + { + Schemas = BuildComponentSchemas() + }; + } + + public Dictionary BuildComponentSchemas() + { + Dictionary schemas = new(); + + foreach (string entityName in _metadataProvider.EntityToDatabaseObject.Keys.ToList()) + { + SourceDefinition? sourceDefinition = _metadataProvider.GetSourceDefinition(entityName); + List columns = sourceDefinition is null ? new List() : sourceDefinition.Columns.Keys.ToList(); + + // create component for FULL entity with PK. + schemas.Add(entityName, CreateComponentSchema(entityName, fields: columns)); + + // create component for entity with no PK + //schea + } + + return schemas; + } + + /// + /// Input needs entity fields and field data type metadata + /// Should this have conditional for creating component with PK field? or should that be handled before + /// and only pass in a field list here to generalize? + /// ex + /// + /// Name of the entity. + /// List of mapped (alias) field names. + /// + public OpenApiSchema CreateComponentSchema(string entityName, List fields) + { + if (!_metadataProvider.EntityToDatabaseObject.TryGetValue(entityName, out DatabaseObject? dbObject) || dbObject is null) + { + throw new DataApiBuilderException(message: "oops bad entity", statusCode: System.Net.HttpStatusCode.InternalServerError, subStatusCode: DataApiBuilderException.SubStatusCodes.OpenApiDocumentAlreadyExists); + } + + Dictionary properties = new(); + + foreach (string field in fields) + { + if(_metadataProvider.TryGetBackingColumn(entityName, field, out string? backingColumnValue) && !string.IsNullOrEmpty(backingColumnValue)) { - Schemas = new Dictionary() + properties.Add(field, new OpenApiSchema() { - { - "BookResponse", - new OpenApiSchema() - { - Type = "object", - Properties = new Dictionary() - { - { - "id", - new OpenApiSchema() - { - Type = "integer", - Format = "int32" - } - }, - { - "title", - new OpenApiSchema() - { - Type = "string" - } - }, - { - "publisher_id", - new OpenApiSchema() - { - Type = "integer", - Format = "int32" - } - } - } - } - } - } - + Type = $"JSON DATA TYPE for {backingColumnValue}", + Format = $"OAS DATA TYPE for {backingColumnValue}" + }); } + } + + OpenApiSchema schema = new() + { + Type = "object", + Properties = properties }; - _openApiDocument = doc; + + return schema; } - public Dictionary BuildComponents() + public JsonValueKind SystemTypeToJsonValueKind(Type type) { - Dictionary components = new(); - Dictionary entityMetadata = _metadataProvider.EntityToDatabaseObject; - return components; + return type.Name switch + { + "String" => JsonValueKind.String, + "Guid" => JsonValueKind.String, + "Byte" => JsonValueKind.String, + "Int16" => JsonValueKind.Number, + "Int32" => JsonValueKind.Number, + "Int64" => JsonValueKind.Number, + "Single" => JsonValueKind.Number, + "Double" => JsonValueKind.Number, + "Decimal" => JsonValueKind.Number, + "Boolean" => JsonValueKind.True, + "DateTime" => JsonValueKind.String, + "DateTimeOffset" => JsonValueKind.String, + "Byte[]" => JsonValueKind.String, + _ => throw new DataApiBuilderException( + message: $"Column type {type} not handled by case. Please add a case resolving {type} to the appropriate GraphQL type", + statusCode: HttpStatusCode.InternalServerError, + subStatusCode: DataApiBuilderException.SubStatusCodes.GraphQLMapping) + }; } /// From 8894daf6b659ba3d442d40c119e961d0d13dcfde Mon Sep 17 00:00:00 2001 From: Sean Leonard Date: Mon, 24 Apr 2023 11:34:24 -0700 Subject: [PATCH 05/30] additional GET path documenting --- src/Service/Services/OpenApiDocumentor.cs | 138 ++++++++++++++++------ 1 file changed, 104 insertions(+), 34 deletions(-) diff --git a/src/Service/Services/OpenApiDocumentor.cs b/src/Service/Services/OpenApiDocumentor.cs index 1e5e8e46e7..e589a00e6e 100644 --- a/src/Service/Services/OpenApiDocumentor.cs +++ b/src/Service/Services/OpenApiDocumentor.cs @@ -8,6 +8,7 @@ using System.IO; using System.Linq; using System.Net; +using System.Text; using System.Text.Json; using Azure.DataApiBuilder.Auth; using Azure.DataApiBuilder.Config; @@ -15,6 +16,7 @@ using Azure.DataApiBuilder.Service.Exceptions; using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Writers; +using Microsoft.VisualStudio.TestTools.UnitTesting; namespace Azure.DataApiBuilder.Service.Services { @@ -78,21 +80,93 @@ public void CreateDocument() { new OpenApiServer { Url = "https://localhost:5000/api/openapi" } }, - Paths = new OpenApiPaths + Paths = BuildPaths(), + Components = BuildComponents() + }; + _openApiDocument = doc; + } + + public OpenApiPaths BuildPaths() + { + // iterate through entities + // BuildPath on entity (which includes Generating Parameters) + // Generate Operations (which include Generating Responses) + OpenApiPaths pathsCollection = new(); + foreach (string entityName in _metadataProvider.EntityToDatabaseObject.Keys.ToList()) + { + Tuple path = BuildPath(entityName); + pathsCollection.Add(path.Item1, path.Item2); + } + + return pathsCollection; + } + + /// + /// Includes Path with Operations(+responses) and Parameters + /// + /// + public Tuple BuildPath(string entityName) + { + // Create entity component + object? entityRestSettings = _runtimeConfig.Entities[entityName].GetRestEnabledOrPathSettings(); + string entityPathValue = entityName; + if (entityRestSettings is not null && entityRestSettings is string) + { + entityPathValue = (string)entityRestSettings; + if (entityPathValue.StartsWith('/')) + { + entityPathValue = entityPathValue.Substring(1); + } + + Assert.IsFalse(string.IsNullOrEmpty(entityPathValue)); + } + + string entityComponent = $"/{entityPathValue}"; + Assert.IsFalse(entityComponent == "/"); + + SourceDefinition sourceDefinition = _metadataProvider.GetSourceDefinition(entityName); + StringBuilder pkComponents = new(); + List parameters = new(); + foreach (string column in sourceDefinition.PrimaryKey) + { + string columnNameForComponent = column; + if (_metadataProvider.TryGetExposedColumnName(entityName, column, out string? mappedColumnAlias) && !string.IsNullOrEmpty(mappedColumnAlias)) { - ["/Book/id/{id}"] = new OpenApiPathItem + columnNameForComponent = mappedColumnAlias; + } + + parameters.Add(new OpenApiParameter() + { + Required = true, + In = ParameterLocation.Path, + Name = $"{columnNameForComponent}", + Schema = new OpenApiSchema() + { + Type = $"JSON DATA TYPE for {column}", + Format = $"OAS DATA TYPE for {column}" + } + }); + string pkComponent = $"/{columnNameForComponent}/{{{columnNameForComponent}}}"; + pkComponents.Append(pkComponent); + } + Assert.IsFalse(string.IsNullOrEmpty(entityComponent)); + // /{entityName/RestPathName} + {/pk/{pkValue}} * N + // CreateFullPathComponentAndParameters() + return new Tuple( + entityComponent + pkComponents.ToString(), + new OpenApiPathItem() + { + Operations = new Dictionary { - Operations = new Dictionary + [OperationType.Get] = new OpenApiOperation { - [OperationType.Get] = new OpenApiOperation + Description = "Returns all pets from the system that the user has access to", + Responses = new OpenApiResponses { - Description = "Returns all pets from the system that the user has access to", - Responses = new OpenApiResponses + ["200"] = new OpenApiResponse { - ["200"] = new OpenApiResponse - { - Description = "OK", - Content = new Dictionary() + Description = "OK", + Content = new Dictionary() { { "application/json", @@ -112,7 +186,7 @@ public void CreateDocument() Reference = new OpenApiReference() { Type = ReferenceType.Schema, - Id = "BookResponse" + Id = $"{entityName}" } } } @@ -123,29 +197,13 @@ public void CreateDocument() } } - } - } - } - }, - Parameters = new List() - { - new OpenApiParameter() - { - Required = true, - In = ParameterLocation.Path, - Name = "id", - Schema = new OpenApiSchema() - { - Type = "integer", - Format = "int32" } } } - } - }, - Components = BuildComponents() - }; - _openApiDocument = doc; + }, + Parameters = parameters + } + ); } public OpenApiComponents BuildComponents() @@ -162,14 +220,26 @@ public Dictionary BuildComponentSchemas() foreach (string entityName in _metadataProvider.EntityToDatabaseObject.Keys.ToList()) { - SourceDefinition? sourceDefinition = _metadataProvider.GetSourceDefinition(entityName); - List columns = sourceDefinition is null ? new List() : sourceDefinition.Columns.Keys.ToList(); + SourceDefinition sourceDefinition = _metadataProvider.GetSourceDefinition(entityName); + List columns = /*sourceDefinition is null ? new List() : */sourceDefinition.Columns.Keys.ToList(); // create component for FULL entity with PK. schemas.Add(entityName, CreateComponentSchema(entityName, fields: columns)); // create component for entity with no PK - //schea + // get list of columns - primary key columns then optimize + + foreach (string primaryKeyColumn in sourceDefinition.PrimaryKey) + { + columns.Remove(primaryKeyColumn); + } + + // create component for TABLE (view?) NOT STOREDPROC entity with no PK. + DatabaseObject dbo = _metadataProvider.EntityToDatabaseObject[entityName]; + if (dbo.SourceType is not SourceType.StoredProcedure or SourceType.View) + { + schemas.Add($"{entityName}_NoPK", CreateComponentSchema(entityName, fields: columns)); + } } return schemas; From 9f2c700cad13f8f18c4d90d56b6d036a51d9a477 Mon Sep 17 00:00:00 2001 From: Sean Leonard Date: Wed, 26 Apr 2023 17:23:29 -0700 Subject: [PATCH 06/30] add openapidoc creation at startup, refine error messages 409 and 500, and add GET (all), POST, and PUT,PATCH,DELETE operations and return results --- src/Config/DataApiBuilderException.cs | 6 +- src/Service/Controllers/OpenApiController.cs | 15 +- src/Service/Services/OpenApiDocumentor.cs | 457 ++++++++++++++++--- src/Service/Startup.cs | 15 + 4 files changed, 420 insertions(+), 73 deletions(-) diff --git a/src/Config/DataApiBuilderException.cs b/src/Config/DataApiBuilderException.cs index fa4bf057b8..6349f23950 100644 --- a/src/Config/DataApiBuilderException.cs +++ b/src/Config/DataApiBuilderException.cs @@ -83,7 +83,11 @@ public enum SubStatusCodes /// /// Attempting to generate OpenAPI document when one already exists. /// - OpenApiDocumentAlreadyExists + OpenApiDocumentAlreadyExists, + /// + /// Attempting to generate OpenAPI document failed. + /// + OpenApiDocumentGenerationFailure } public HttpStatusCode StatusCode { get; } diff --git a/src/Service/Controllers/OpenApiController.cs b/src/Service/Controllers/OpenApiController.cs index 27cd241f27..55b7d29d8a 100644 --- a/src/Service/Controllers/OpenApiController.cs +++ b/src/Service/Controllers/OpenApiController.cs @@ -3,6 +3,7 @@ using System; using System.Net; +using Azure.DataApiBuilder.Service.Exceptions; using Azure.DataApiBuilder.Service.Services; using Microsoft.AspNetCore.Mvc; @@ -20,7 +21,6 @@ public class OpenApiController : ControllerBase public OpenApiController(IOpenApiDocumentor openApiDocumentor) { _apiDocumentor = openApiDocumentor; - Console.WriteLine("api controller constructor"); } /// @@ -51,9 +51,18 @@ public IActionResult Post() return NotFound(); } - catch (Exception) + catch (DataApiBuilderException dabException) { - return new StatusCodeResult(statusCode: (int)HttpStatusCode.InternalServerError); + Response.StatusCode = (int)dabException.StatusCode; + return new JsonResult(new + { + error = new + { + code = dabException.SubStatusCode.ToString(), + message = dabException.Message, + status = (int)dabException.StatusCode + } + }); } } } diff --git a/src/Service/Services/OpenApiDocumentor.cs b/src/Service/Services/OpenApiDocumentor.cs index e589a00e6e..8351866f21 100644 --- a/src/Service/Services/OpenApiDocumentor.cs +++ b/src/Service/Services/OpenApiDocumentor.cs @@ -64,26 +64,38 @@ public void CreateDocument() if (_openApiDocument is not null) { throw new DataApiBuilderException( - message: "already created", - statusCode: System.Net.HttpStatusCode.Conflict, + message: "OpenAPI description document already generated.", + statusCode: HttpStatusCode.Conflict, subStatusCode: DataApiBuilderException.SubStatusCodes.OpenApiDocumentAlreadyExists); } - OpenApiDocument doc = new() + try { - Info = new OpenApiInfo + OpenApiDocument doc = new() { - Version = "0.0.1", - Title = "Data API builder - OpenAPI Description Document", - }, - Servers = new List + Info = new OpenApiInfo + { + Version = "0.0.1", + Title = "Data API builder - OpenAPI Description Document", + }, + Servers = new List { new OpenApiServer { Url = "https://localhost:5000/api/openapi" } }, - Paths = BuildPaths(), - Components = BuildComponents() - }; - _openApiDocument = doc; + Paths = BuildPaths(), + Components = BuildComponents() + }; + _openApiDocument = doc; + } + catch(Exception ex) + { + throw new DataApiBuilderException( + message: "OpenAPI description document generation failed.", + statusCode: HttpStatusCode.InternalServerError, + subStatusCode: DataApiBuilderException.SubStatusCodes.OpenApiDocumentGenerationFailure, + innerException: ex); + } + } public OpenApiPaths BuildPaths() @@ -94,42 +106,139 @@ public OpenApiPaths BuildPaths() OpenApiPaths pathsCollection = new(); foreach (string entityName in _metadataProvider.EntityToDatabaseObject.Keys.ToList()) { + // First tuple returned is path which includes PK in route (GET (by ID), PUT, PATCH, DELETE + // and POST when PK is not autogenerated Tuple path = BuildPath(entityName); pathsCollection.Add(path.Item1, path.Item2); + // Second tuple returned for GET (all) and POST when PK is autogenerated. + Tuple pathGetAllPost = BuildGetAllAndPostPath(entityName); + pathsCollection.TryAdd(pathGetAllPost.Item1, pathGetAllPost.Item2); } return pathsCollection; } + public Tuple BuildGetAllAndPostPath(string entityName) + { + string entityRestPath = GetEntityRestPath(entityName); + + string entityBasePathComponent = $"/{entityRestPath}"; + Assert.IsFalse(entityBasePathComponent == "/"); + + // Can a composite primary key have mix of auto/non-autogen'd fields? + SourceDefinition sourceDefinition = _metadataProvider.GetSourceDefinition(entityName); + // Parameters are the placeholders for pk values in curly braces { } in the URL route + // localhost:5000/api/Entity/pk1/{pk1}/pk2/{pk2} + bool sourceObjectHasAutogeneratedPK = false; + bool requestBodyRequired = IsRequestBodyRequired(sourceDefinition); + + List tags = new() + { + // Explicitly leave out description which can note the actual entity name when + // a REST path override is used because it may not be desired to expose that name. + new OpenApiTag() + { + Name = entityRestPath + } + }; + + Assert.IsFalse(string.IsNullOrEmpty(entityBasePathComponent)); + // /{entityName/RestPathName} + {/pk/{pkValue}} * N + // CreateFullPathComponentAndParameters() + return new Tuple( + entityBasePathComponent, + new OpenApiPathItem() + { + Operations = new Dictionary() + { + // Creation GET, POST, PUT, PATCH, DELETE operations + [OperationType.Get] = new OpenApiOperation() + { + Description = "Returns entities.", + Tags = tags, + Responses = new OpenApiResponses() + { + ["200"] = CreateResponse(description: "OK", responseObjectSchemaName: entityName), + ["400"] = CreateResponse(description: "Bad Request"), + ["401"] = CreateResponse(description: "Unauthorized"), + ["403"] = CreateResponse(description: "Forbidden"), + ["404"] = CreateResponse(description: "Not Found") + } + }, + [OperationType.Post] = new OpenApiOperation() + { + Description = "Create entity.", + Tags = tags, + Responses = new OpenApiResponses() + { + ["201"] = CreateResponse(description: "Created", responseObjectSchemaName: entityName), + ["400"] = CreateResponse(description: "Bad Request"), + ["401"] = CreateResponse(description: "Unauthorized"), + ["403"] = CreateResponse(description: "Forbidden"), + ["404"] = CreateResponse(description: "Not Found"), + ["409"] = CreateResponse(description: "Conflict") + }, + RequestBody = new OpenApiRequestBody() + { + Content = new Dictionary() + { + { + "application/json", + new() + { + Schema = new OpenApiSchema() + { + // when a post request on entity with PK autogenerated, do not allow pk in post body. + Reference = new OpenApiReference() + { + Type = ReferenceType.Schema, + Id = sourceObjectHasAutogeneratedPK ? $"{entityName}_NoPK" : $"{entityName}" + } + } + } + } + }, + // This should be conditioned on whether the table's columns either + // autogenerated + // nullable + // hasDefaultValue + Required = requestBodyRequired + } + } + } + } + ); + } + /// /// Includes Path with Operations(+responses) and Parameters /// /// public Tuple BuildPath(string entityName) { - // Create entity component - object? entityRestSettings = _runtimeConfig.Entities[entityName].GetRestEnabledOrPathSettings(); - string entityPathValue = entityName; - if (entityRestSettings is not null && entityRestSettings is string) - { - entityPathValue = (string)entityRestSettings; - if (entityPathValue.StartsWith('/')) - { - entityPathValue = entityPathValue.Substring(1); - } - - Assert.IsFalse(string.IsNullOrEmpty(entityPathValue)); - } + string entityRestPath = GetEntityRestPath(entityName); - string entityComponent = $"/{entityPathValue}"; - Assert.IsFalse(entityComponent == "/"); + string entityBasePathComponent = $"/{entityRestPath}"; + Assert.IsFalse(entityBasePathComponent == "/"); + // Can a composite primary key have mix of auto/non-autogen'd fields? SourceDefinition sourceDefinition = _metadataProvider.GetSourceDefinition(entityName); StringBuilder pkComponents = new(); + // Parameters are the placeholders for pk values in curly braces { } in the URL route + // localhost:5000/api/Entity/pk1/{pk1}/pk2/{pk2} List parameters = new(); + bool sourceObjectHasAutogeneratedPK = false; + bool requestBodyRequired = IsRequestBodyRequired(sourceDefinition); + foreach (string column in sourceDefinition.PrimaryKey) { string columnNameForComponent = column; + + if (sourceDefinition.Columns.TryGetValue(columnNameForComponent, out ColumnDefinition? columnDef) && columnDef is not null && columnDef.IsAutoGenerated) + { + sourceObjectHasAutogeneratedPK = true; + } + if (_metadataProvider.TryGetExposedColumnName(entityName, column, out string? mappedColumnAlias) && !string.IsNullOrEmpty(mappedColumnAlias)) { columnNameForComponent = mappedColumnAlias; @@ -142,62 +251,138 @@ public Tuple BuildPath(string entityName) Name = $"{columnNameForComponent}", Schema = new OpenApiSchema() { - Type = $"JSON DATA TYPE for {column}", - Format = $"OAS DATA TYPE for {column}" + Type = (columnDef is not null) ? SystemTypeToJsonValueKind(columnDef.SystemType).ToString().ToLower() : string.Empty } }); string pkComponent = $"/{columnNameForComponent}/{{{columnNameForComponent}}}"; pkComponents.Append(pkComponent); } - Assert.IsFalse(string.IsNullOrEmpty(entityComponent)); + + List tags = new() + { + // Explicitly leave out description which can note the actual entity name when + // a REST path override is used because it may not be desired to expose that name. + new OpenApiTag() + { + Name = entityRestPath + } + }; + + Assert.IsFalse(string.IsNullOrEmpty(entityBasePathComponent)); // /{entityName/RestPathName} + {/pk/{pkValue}} * N // CreateFullPathComponentAndParameters() return new Tuple( - entityComponent + pkComponents.ToString(), + entityBasePathComponent + pkComponents.ToString(), new OpenApiPathItem() { - Operations = new Dictionary + Operations = new Dictionary() { - [OperationType.Get] = new OpenApiOperation + // Creation GET, POST, PUT, PATCH, DELETE operations + [OperationType.Get] = new OpenApiOperation() { - Description = "Returns all pets from the system that the user has access to", - Responses = new OpenApiResponses + Description = "Returns an entity.", + Tags = tags, + Responses = new OpenApiResponses() + { + ["200"] = CreateResponse(description: "OK", responseObjectSchemaName: entityName), + ["400"] = CreateResponse(description: "Bad Request"), + ["401"] = CreateResponse(description: "Unauthorized"), + ["403"] = CreateResponse(description: "Forbidden"), + ["404"] = CreateResponse(description: "Not Found") + } + }, + [OperationType.Put] = new OpenApiOperation() + { + Description = "Replace or Create entity/entities.", + Tags = tags, + Responses = new OpenApiResponses() + { + ["200"] = CreateResponse(description: "OK", responseObjectSchemaName: entityName), + ["201"] = CreateResponse(description: "Created", responseObjectSchemaName: entityName), + ["400"] = CreateResponse(description: "Bad Request"), + ["401"] = CreateResponse(description: "Unauthorized"), + ["403"] = CreateResponse(description: "Forbidden"), + ["404"] = CreateResponse(description: "Not Found") + }, + RequestBody = new OpenApiRequestBody() { - ["200"] = new OpenApiResponse + Content = new Dictionary() { - Description = "OK", - Content = new Dictionary() + { + "application/json", + new() { + Schema = new OpenApiSchema() { - "application/json", - new OpenApiMediaType() + // when a post request on entity with PK autogenerated, do not allow pk in post body. + Reference = new OpenApiReference() { - Schema = new OpenApiSchema() - { - Properties = new Dictionary() - { - { - "value", - new OpenApiSchema() - { - Type = "array", - Items = new OpenApiSchema() - { - Reference = new OpenApiReference() - { - Type = ReferenceType.Schema, - Id = $"{entityName}" - } - } - } - } - } - } + Type = ReferenceType.Schema, + Id = sourceObjectHasAutogeneratedPK ? $"{entityName}_NoPK" : $"{entityName}" } } } - - } + } + }, + // This should be conditioned on whether the table's columns either + // autogenerated + // nullable + // hasDefaultValue + Required = requestBodyRequired + } + }, + [OperationType.Patch] = new OpenApiOperation() + { + Description = "Update or Create entity/entities.", + Tags = tags, + Responses = new OpenApiResponses() + { + ["200"] = CreateResponse(description: "OK", responseObjectSchemaName: entityName), + ["201"] = CreateResponse(description: "Created", responseObjectSchemaName: entityName), + ["400"] = CreateResponse(description: "Bad Request"), + ["401"] = CreateResponse(description: "Unauthorized"), + ["403"] = CreateResponse(description: "Forbidden"), + ["404"] = CreateResponse(description: "Not Found") + }, + RequestBody = new OpenApiRequestBody() + { + Content = new Dictionary() + { + { + "application/json", + new() + { + Schema = new OpenApiSchema() + { + // when a post request on entity with PK autogenerated, do not allow pk in post body. + Reference = new OpenApiReference() + { + Type = ReferenceType.Schema, + Id = sourceObjectHasAutogeneratedPK ? $"{entityName}_NoPK" : $"{entityName}" + } + } + } + } + }, + // This should be conditioned on whether the table's columns either + // autogenerated + // nullable + // hasDefaultValue + Required = requestBodyRequired + } + }, + [OperationType.Delete] = new OpenApiOperation() + { + Description = "Delete entity/entities.", + Tags = tags, + Responses = new OpenApiResponses() + { + ["200"] = CreateResponse(description: "OK", responseObjectSchemaName: entityName), + ["204"] = CreateResponse(description: "No Content", responseObjectSchemaName: entityName), + ["400"] = CreateResponse(description: "Bad Request"), + ["401"] = CreateResponse(description: "Unauthorized"), + ["403"] = CreateResponse(description: "Forbidden"), + ["404"] = CreateResponse(description: "Not Found") } } }, @@ -206,6 +391,58 @@ public Tuple BuildPath(string entityName) ); } + /// + /// Evaluates a table's columns to determine whethere + /// + /// + /// + public bool IsRequestBodyRequired(SourceDefinition sourceDef) + { + foreach (KeyValuePair columnMetadata in sourceDef.Columns) + { + // Primary Key Check -> Body shouldn't have primary key if it is autogenerated. + if (sourceDef.PrimaryKey.Contains(columnMetadata.Key) && columnMetadata.Value.IsAutoGenerated) + { + continue; + } + + // The presence of a non-PK column which does not have any of the following properties + // results in the body being required. + if (columnMetadata.Value.HasDefault || columnMetadata.Value.IsNullable || columnMetadata.Value.IsAutoGenerated) + { + continue; + } + + return false; + } + + return true; + } + + /// + /// Resolves any REST path overrides present for the provided entity in the runtime config. + /// If no overrides exist, returns the passed in entity name. + /// + /// Name of the entity. + /// Returns the REST path name for the provided entity. + public string GetEntityRestPath(string entityName) + { + object? entityRestSettings = _runtimeConfig.Entities[entityName].GetRestEnabledOrPathSettings(); + string entityRestPath = entityName; + if (entityRestSettings is not null && entityRestSettings is string) + { + entityRestPath = (string)entityRestSettings; + if (!string.IsNullOrEmpty(entityRestPath) && entityRestPath.StartsWith('/')) + { + // Remove slash from start of rest path. + entityRestPath = entityRestPath.Substring(1); + } + } + + Assert.IsFalse(string.Equals('/', entityRestPath)); + return entityRestPath; + } + public OpenApiComponents BuildComponents() { return new OpenApiComponents() @@ -214,6 +451,82 @@ public OpenApiComponents BuildComponents() }; } + public OpenApiResponse CreateResponse(string description, string? responseObjectSchemaName = null) + { + OpenApiResponse response = new() + { + Description = description + }; + + // No entityname means there no example response object schema should be included. + // the entityname references the schema of the response object. + if (!string.IsNullOrEmpty(responseObjectSchemaName)) + { + response.Content = new Dictionary() + { + { + "application/json", + new() + { + Schema = new OpenApiSchema() + { + Properties = new Dictionary() + { + { + "value", + new OpenApiSchema() + { + Type = "array", + Items = new OpenApiSchema() + { + Reference = new OpenApiReference() + { + Type = ReferenceType.Schema, + Id = $"{responseObjectSchemaName}" + } + } + } + } + } + } + } + } + }; + } + + return response; + } + + public OpenApiMediaType GetResponsePayload(string entityName) + { + OpenApiMediaType responsePayload = new() + { + Schema = new OpenApiSchema() + { + Properties = new Dictionary() + { + { + "value", + new OpenApiSchema() + { + Type = "array", + Items = new OpenApiSchema() + { + Reference = new OpenApiReference() + { + Type = ReferenceType.Schema, + Id = $"{entityName}" + } + } + } + } + } + } + }; + + return responsePayload; + } + public Dictionary BuildComponentSchemas() { Dictionary schemas = new(); @@ -235,7 +548,7 @@ public Dictionary BuildComponentSchemas() } // create component for TABLE (view?) NOT STOREDPROC entity with no PK. - DatabaseObject dbo = _metadataProvider.EntityToDatabaseObject[entityName]; + DatabaseObject dbo = _metadataProvider.EntityToDatabaseObject[entityName]; if (dbo.SourceType is not SourceType.StoredProcedure or SourceType.View) { schemas.Add($"{entityName}_NoPK", CreateComponentSchema(entityName, fields: columns)); @@ -261,16 +574,22 @@ public OpenApiSchema CreateComponentSchema(string entityName, List field throw new DataApiBuilderException(message: "oops bad entity", statusCode: System.Net.HttpStatusCode.InternalServerError, subStatusCode: DataApiBuilderException.SubStatusCodes.OpenApiDocumentAlreadyExists); } - Dictionary properties = new(); - + Dictionary properties = new(); foreach (string field in fields) { - if(_metadataProvider.TryGetBackingColumn(entityName, field, out string? backingColumnValue) && !string.IsNullOrEmpty(backingColumnValue)) + if (_metadataProvider.TryGetBackingColumn(entityName, field, out string? backingColumnValue) && !string.IsNullOrEmpty(backingColumnValue)) { + string typeMetadata = string.Empty; + string formatMetadata = string.Empty; + if (dbObject.SourceDefinition.Columns.TryGetValue(backingColumnValue, out ColumnDefinition? columnDef) && columnDef is not null) + { + typeMetadata = SystemTypeToJsonValueKind(columnDef.SystemType).ToString().ToLower(); + } + properties.Add(field, new OpenApiSchema() { - Type = $"JSON DATA TYPE for {backingColumnValue}", - Format = $"OAS DATA TYPE for {backingColumnValue}" + Type = typeMetadata, + Format = formatMetadata }); } } diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index 136d01032c..7516c81ac9 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -630,6 +630,21 @@ private async Task PerformOnConfigChangeAsync(IApplicationBuilder app) runtimeConfigValidator.ValidateStoredProceduresInConfig(runtimeConfig, sqlMetadataProvider!); + // Attempt to create OpenAPI document. + // Any failures should result in a logged error, but must not halt runtime initialization + // because OpenAPI document creation is not required for the engine to operate. + try + { + IOpenApiDocumentor openApiDocumentor = + app.ApplicationServices.GetRequiredService(); + + openApiDocumentor.CreateDocument(); + } + catch (DataApiBuilderException dabException) + { + _logger.LogError($"{dabException.Message}"); + } + _logger.LogInformation($"Successfully completed runtime initialization."); return true; } From 148863675ca29dc631d38b48b3b23d1b638b89a6 Mon Sep 17 00:00:00 2001 From: Sean Leonard Date: Thu, 27 Apr 2023 16:18:04 -0700 Subject: [PATCH 07/30] Consolidated code paths, reduced code verbosity/duplication. Comments. --- src/Service/Services/OpenApiDocumentor.cs | 720 +++++++++++----------- 1 file changed, 357 insertions(+), 363 deletions(-) diff --git a/src/Service/Services/OpenApiDocumentor.cs b/src/Service/Services/OpenApiDocumentor.cs index 8351866f21..6fae615a44 100644 --- a/src/Service/Services/OpenApiDocumentor.cs +++ b/src/Service/Services/OpenApiDocumentor.cs @@ -10,7 +10,6 @@ using System.Net; using System.Text; using System.Text.Json; -using Azure.DataApiBuilder.Auth; using Azure.DataApiBuilder.Config; using Azure.DataApiBuilder.Service.Configurations; using Azure.DataApiBuilder.Service.Exceptions; @@ -20,26 +19,44 @@ namespace Azure.DataApiBuilder.Service.Services { + /// + /// Service which generates and provides an OpenAPI description document + /// describing the DAB engine's REST endpoint paths. + /// public class OpenApiDocumentor : IOpenApiDocumentor { private ISqlMetadataProvider _metadataProvider; - private IAuthorizationResolver _authorizationResolver; - private OpenApiDocument? _openApiDocument; private RuntimeConfig _runtimeConfig; + private OpenApiResponses _defaultOpenApiResponses; + private OpenApiDocument? _openApiDocument; - public OpenApiDocumentor(ISqlMetadataProvider sqlMetadataProvider, IAuthorizationResolver authorizationResolver, RuntimeConfigProvider runtimeConfigProvider) + private const string JSON_MEDIA_TYPE = "application/json"; + private const string GETALL_DESCRIPTION = "Returns entities."; + private const string GETONE_DESCRIPTION = "Returns an entity."; + private const string POST_DESCRIPTION = "Create entity."; + private const string PUT_DESCRIPTION = "Replace or create entity."; + private const string PATCH_DESCRIPTION = "Update or create entity."; + private const string DELETE_DESCRIPTION = "Delete entity."; + private const string RESPONSE_VALUE_PROPERTY = "value"; + private const string RESPONSE_ARRAY_PROPERTY = "array"; + + /// + /// Constructor denotes required services whose metadata is used to generate the OpenAPI description document. + /// + /// Provides database object metadata. + /// Provides entity/REST path metadata. + public OpenApiDocumentor(ISqlMetadataProvider sqlMetadataProvider, RuntimeConfigProvider runtimeConfigProvider) { _metadataProvider = sqlMetadataProvider; - _authorizationResolver = authorizationResolver; - _openApiDocument = null; _runtimeConfig = runtimeConfigProvider.GetRuntimeConfiguration(); + _defaultOpenApiResponses = CreateDefaultOpenApiResponses(); } /// - /// + /// Attempts to return an OpenAPI description document, if generated. /// - /// - /// + /// String representation of JSON OpenAPI description document. + /// True (plus string representation of document), when document exists. False, otherwise. public bool TryGetDocument([NotNullWhen(true)] out string? document) { if (_openApiDocument is null) @@ -59,6 +76,13 @@ public bool TryGetDocument([NotNullWhen(true)] out string? document) } } + /// + /// Creates an OpenAPI description document using OpenAPI.NET. + /// Document compliant with all patches of OpenAPI V3.0 spec (e.g. 3.0.0, 3.0.1) + /// + /// Raised when document is already generated + /// or a failure occurs during generation. + /// public void CreateDocument() { if (_openApiDocument is not null) @@ -71,19 +95,24 @@ public void CreateDocument() try { + OpenApiComponents components = new() + { + Schemas = CreateComponentSchemas() + }; + OpenApiDocument doc = new() { Info = new OpenApiInfo { - Version = "0.0.1", - Title = "Data API builder - OpenAPI Description Document", + Version = "PREVIEW", + Title = "Data API builder - REST Endpoint", }, Servers = new List - { - new OpenApiServer { Url = "https://localhost:5000/api/openapi" } - }, + { + new OpenApiServer { Url = "https://localhost:5000/api/openapi" } + }, Paths = BuildPaths(), - Components = BuildComponents() + Components = components }; _openApiDocument = doc; } @@ -95,317 +124,278 @@ public void CreateDocument() subStatusCode: DataApiBuilderException.SubStatusCodes.OpenApiDocumentGenerationFailure, innerException: ex); } - } - public OpenApiPaths BuildPaths() + /// + /// Iteratrs through the runtime configuration's entities and generates the path object + /// representing the DAB engine's supported HTTP verbs and relevant route restrictions: + /// Routes including primary key: + /// - GET (by ID), PUT, PATCH, DELETE + /// Routes excluding primary key: + /// - GET (all), POST + /// + /// All possible paths in the DAB engine's REST API endpoint. + private OpenApiPaths BuildPaths() { - // iterate through entities - // BuildPath on entity (which includes Generating Parameters) - // Generate Operations (which include Generating Responses) OpenApiPaths pathsCollection = new(); + foreach (string entityName in _metadataProvider.EntityToDatabaseObject.Keys.ToList()) { - // First tuple returned is path which includes PK in route (GET (by ID), PUT, PATCH, DELETE - // and POST when PK is not autogenerated - Tuple path = BuildPath(entityName); + // Routes including primary key + Tuple path = BuildPath(entityName, includePrimaryKeyPathComponent: false); pathsCollection.Add(path.Item1, path.Item2); - // Second tuple returned for GET (all) and POST when PK is autogenerated. - Tuple pathGetAllPost = BuildGetAllAndPostPath(entityName); + + // Routes excluding primary key + Tuple pathGetAllPost = BuildPath(entityName, includePrimaryKeyPathComponent: true); pathsCollection.TryAdd(pathGetAllPost.Item1, pathGetAllPost.Item2); } return pathsCollection; } - public Tuple BuildGetAllAndPostPath(string entityName) + /// + /// Includes Path with Operations(+responses) and Parameters + /// Parameters are the placeholders for pk values in curly braces { } in the URL route + /// localhost:5000/api/Entity/pk1/{pk1}/pk2/{pk2} + /// /{entityName/RestPathName} + {/pk/{pkValue}} * N + /// + /// + private Tuple BuildPath(string entityName, bool includePrimaryKeyPathComponent) { - string entityRestPath = GetEntityRestPath(entityName); + SourceDefinition sourceDefinition = _metadataProvider.GetSourceDefinition(entityName); + string entityRestPath = GetEntityRestPath(entityName); string entityBasePathComponent = $"/{entityRestPath}"; - Assert.IsFalse(entityBasePathComponent == "/"); - // Can a composite primary key have mix of auto/non-autogen'd fields? - SourceDefinition sourceDefinition = _metadataProvider.GetSourceDefinition(entityName); - // Parameters are the placeholders for pk values in curly braces { } in the URL route - // localhost:5000/api/Entity/pk1/{pk1}/pk2/{pk2} - bool sourceObjectHasAutogeneratedPK = false; + // When the source's primary key(s) are autogenerated, the PUT, PATCH, and POST request + // bodies must not include the primary key(s). + string schemaReferenceId = SourceContainsAutogeneratedPrimaryKey(sourceDefinition) ? $"{entityName}_NoPK" : $"{entityName}"; + bool requestBodyRequired = IsRequestBodyRequired(sourceDefinition); + // Explicitly exclude setting the tag's Description property since the Name property is self-explanatory. + OpenApiTag openApiTag = new() + { + Name = entityRestPath + }; + + // The OpenApiTag will categorize all paths created using the entity's name or overridden REST path value. + // The tag categorization will instruct OpenAPI document visualization tooling to display all generated paths together. List tags = new() { - // Explicitly leave out description which can note the actual entity name when - // a REST path override is used because it may not be desired to expose that name. - new OpenApiTag() - { - Name = entityRestPath - } + openApiTag }; + + if (includePrimaryKeyPathComponent) + { + Tuple> pkComponents = CreatePrimaryKeyPathComponentAndParameters(entityName); + string pkPathComponents = pkComponents.Item1; + string fullPathComponent = entityBasePathComponent + pkPathComponents; - Assert.IsFalse(string.IsNullOrEmpty(entityBasePathComponent)); - // /{entityName/RestPathName} + {/pk/{pkValue}} * N - // CreateFullPathComponentAndParameters() - return new Tuple( - entityBasePathComponent, - new OpenApiPathItem() + OpenApiOperation getOperation = new() + { + Description = GETONE_DESCRIPTION, + Tags = tags, + Responses = new(_defaultOpenApiResponses), + }; + getOperation.Responses.Add(HttpStatusCode.OK.ToString(), CreateOpenApiResponse(description: nameof(HttpStatusCode.OK), responseObjectSchemaName: entityName)); + + OpenApiOperation putOperation = new() + { + Description = PUT_DESCRIPTION, + Tags = tags, + Responses = new(_defaultOpenApiResponses), + RequestBody = CreateOpenApiRequestBodyPayload(schemaReferenceId, requestBodyRequired) + }; + putOperation.Responses.Add(HttpStatusCode.OK.ToString(), CreateOpenApiResponse(description: nameof(HttpStatusCode.OK), responseObjectSchemaName: entityName)); + putOperation.Responses.Add(HttpStatusCode.Created.ToString(), CreateOpenApiResponse(description: nameof(HttpStatusCode.Created), responseObjectSchemaName: entityName)); + + OpenApiOperation patchOperation = new() + { + Description = PATCH_DESCRIPTION, + Tags = tags, + Responses = new(_defaultOpenApiResponses), + RequestBody = CreateOpenApiRequestBodyPayload(schemaReferenceId, requestBodyRequired) + }; + patchOperation.Responses.Add(HttpStatusCode.OK.ToString(), CreateOpenApiResponse(description: nameof(HttpStatusCode.OK), responseObjectSchemaName: entityName)); + patchOperation.Responses.Add(HttpStatusCode.Created.ToString(), CreateOpenApiResponse(description: nameof(HttpStatusCode.Created), responseObjectSchemaName: entityName)); + + OpenApiOperation deleteOperation = new() + { + Description = DELETE_DESCRIPTION, + Tags = tags, + Responses = new(_defaultOpenApiResponses), + RequestBody = CreateOpenApiRequestBodyPayload(schemaReferenceId, requestBodyRequired) + }; + deleteOperation.Responses.Add(HttpStatusCode.OK.ToString(), CreateOpenApiResponse(description: nameof(HttpStatusCode.OK), responseObjectSchemaName: entityName)); + deleteOperation.Responses.Add(HttpStatusCode.Created.ToString(), CreateOpenApiResponse(description: nameof(HttpStatusCode.Created), responseObjectSchemaName: entityName)); + + OpenApiPathItem openApiPathItem = new() { Operations = new Dictionary() { // Creation GET, POST, PUT, PATCH, DELETE operations - [OperationType.Get] = new OpenApiOperation() - { - Description = "Returns entities.", - Tags = tags, - Responses = new OpenApiResponses() - { - ["200"] = CreateResponse(description: "OK", responseObjectSchemaName: entityName), - ["400"] = CreateResponse(description: "Bad Request"), - ["401"] = CreateResponse(description: "Unauthorized"), - ["403"] = CreateResponse(description: "Forbidden"), - ["404"] = CreateResponse(description: "Not Found") - } - }, - [OperationType.Post] = new OpenApiOperation() + [OperationType.Get] = getOperation, + [OperationType.Put] = putOperation, + [OperationType.Patch] = patchOperation, + [OperationType.Delete] = deleteOperation + }, + Parameters = pkComponents.Item2 + }; + + return new(fullPathComponent, openApiPathItem); + } + else + { + OpenApiOperation getAllOperation = new() + { + Description = GETALL_DESCRIPTION, + Tags = tags, + Responses = new(_defaultOpenApiResponses), + RequestBody = CreateOpenApiRequestBodyPayload(schemaReferenceId, requestBodyRequired) + }; + getAllOperation.Responses.Add(HttpStatusCode.OK.ToString(), CreateOpenApiResponse(description: nameof(HttpStatusCode.OK), responseObjectSchemaName: entityName)); + + OpenApiOperation postOperation = new() + { + Description = POST_DESCRIPTION, + Tags = tags, + Responses = new(_defaultOpenApiResponses), + RequestBody = CreateOpenApiRequestBodyPayload(schemaReferenceId, requestBodyRequired) + }; + postOperation.Responses.Add(HttpStatusCode.Created.ToString(), CreateOpenApiResponse(description: nameof(HttpStatusCode.Created), responseObjectSchemaName: entityName)); + postOperation.Responses.Add(HttpStatusCode.Conflict.ToString(), CreateOpenApiResponse(description: nameof(HttpStatusCode.Conflict), responseObjectSchemaName: entityName)); + + OpenApiPathItem openApiPathItem = new() + { + Operations = new Dictionary() + { + [OperationType.Get] = getAllOperation, + [OperationType.Post] = postOperation + } + }; + + return new(entityBasePathComponent, openApiPathItem); + } + } + + /// + /// Creates the request body definition, which includes the expected media type (application/json) + /// and reference to request body schema. + /// + /// Request body schema object name: For POST requests on entity + /// where the primary key is autogenerated, do not allow PK in post body. + /// True/False, conditioned on whether the table's columns are either + /// autogenerated, nullable, or hasDefaultValue + /// Request payload. + private static OpenApiRequestBody CreateOpenApiRequestBodyPayload(string schemaReferenceId, bool requestBodyRequired) + { + OpenApiRequestBody requestBody = new() + { + Content = new Dictionary() + { + { + JSON_MEDIA_TYPE, + new() { - Description = "Create entity.", - Tags = tags, - Responses = new OpenApiResponses() - { - ["201"] = CreateResponse(description: "Created", responseObjectSchemaName: entityName), - ["400"] = CreateResponse(description: "Bad Request"), - ["401"] = CreateResponse(description: "Unauthorized"), - ["403"] = CreateResponse(description: "Forbidden"), - ["404"] = CreateResponse(description: "Not Found"), - ["409"] = CreateResponse(description: "Conflict") - }, - RequestBody = new OpenApiRequestBody() + Schema = new OpenApiSchema() { - Content = new Dictionary() + Reference = new OpenApiReference() { - { - "application/json", - new() - { - Schema = new OpenApiSchema() - { - // when a post request on entity with PK autogenerated, do not allow pk in post body. - Reference = new OpenApiReference() - { - Type = ReferenceType.Schema, - Id = sourceObjectHasAutogeneratedPK ? $"{entityName}_NoPK" : $"{entityName}" - } - } - } - } - }, - // This should be conditioned on whether the table's columns either - // autogenerated - // nullable - // hasDefaultValue - Required = requestBodyRequired + Type = ReferenceType.Schema, + Id = schemaReferenceId + } } } } - } - ); + }, + Required = requestBodyRequired + }; + + return requestBody; } /// - /// Includes Path with Operations(+responses) and Parameters + /// Parameters are the placeholders for pk values in curly braces { } in the URL route + /// https://localhost:5000/api/Entity/pk1/{pk1}/pk2/{pk2} /// - /// - public Tuple BuildPath(string entityName) + /// Name of the entity. + /// Primary Key path component. Empty string if no primary keys exist on database object source definition. + private Tuple> CreatePrimaryKeyPathComponentAndParameters(string entityName) { - string entityRestPath = GetEntityRestPath(entityName); - - string entityBasePathComponent = $"/{entityRestPath}"; - Assert.IsFalse(entityBasePathComponent == "/"); - - // Can a composite primary key have mix of auto/non-autogen'd fields? SourceDefinition sourceDefinition = _metadataProvider.GetSourceDefinition(entityName); - StringBuilder pkComponents = new(); - // Parameters are the placeholders for pk values in curly braces { } in the URL route - // localhost:5000/api/Entity/pk1/{pk1}/pk2/{pk2} List parameters = new(); - bool sourceObjectHasAutogeneratedPK = false; - bool requestBodyRequired = IsRequestBodyRequired(sourceDefinition); + StringBuilder pkComponents = new(); foreach (string column in sourceDefinition.PrimaryKey) { string columnNameForComponent = column; - if (sourceDefinition.Columns.TryGetValue(columnNameForComponent, out ColumnDefinition? columnDef) && columnDef is not null && columnDef.IsAutoGenerated) - { - sourceObjectHasAutogeneratedPK = true; - } - if (_metadataProvider.TryGetExposedColumnName(entityName, column, out string? mappedColumnAlias) && !string.IsNullOrEmpty(mappedColumnAlias)) { columnNameForComponent = mappedColumnAlias; } - parameters.Add(new OpenApiParameter() + if (sourceDefinition.Columns.TryGetValue(columnNameForComponent, out ColumnDefinition? columnDef)) { - Required = true, - In = ParameterLocation.Path, - Name = $"{columnNameForComponent}", - Schema = new OpenApiSchema() + OpenApiSchema parameterSchema = new() { - Type = (columnDef is not null) ? SystemTypeToJsonValueKind(columnDef.SystemType).ToString().ToLower() : string.Empty - } - }); - string pkComponent = $"/{columnNameForComponent}/{{{columnNameForComponent}}}"; - pkComponents.Append(pkComponent); + Type = (columnDef is not null) ? SystemTypeToJsonValueKind(columnDef.SystemType) : string.Empty + }; + + OpenApiParameter openApiParameter = new() + { + Required = true, + In = ParameterLocation.Path, + Name = $"{columnNameForComponent}", + Schema = parameterSchema + }; + + parameters.Add(openApiParameter); + string pkComponent = $"/{columnNameForComponent}/{{{columnNameForComponent}}}"; + pkComponents.Append(pkComponent); + } } - List tags = new() + return new(pkComponents.ToString(), parameters); + } + + /// + /// Determines whether the database object has an autogenerated primary key + /// used to distinguish which request and response objects should reference the primary key. + /// + /// Database object metadata. + /// True, when the primary key is autogenerated. Otherwise, false. + private static bool SourceContainsAutogeneratedPrimaryKey(SourceDefinition sourceDefinition) + { + bool sourceObjectHasAutogeneratedPK = false; + // Create primary key path component. + foreach (string column in sourceDefinition.PrimaryKey) { - // Explicitly leave out description which can note the actual entity name when - // a REST path override is used because it may not be desired to expose that name. - new OpenApiTag() - { - Name = entityRestPath - } - }; + string columnNameForComponent = column; - Assert.IsFalse(string.IsNullOrEmpty(entityBasePathComponent)); - // /{entityName/RestPathName} + {/pk/{pkValue}} * N - // CreateFullPathComponentAndParameters() - return new Tuple( - entityBasePathComponent + pkComponents.ToString(), - new OpenApiPathItem() + if (sourceDefinition.Columns.TryGetValue(columnNameForComponent, out ColumnDefinition? columnDef) && columnDef is not null && columnDef.IsAutoGenerated) { - Operations = new Dictionary() - { - // Creation GET, POST, PUT, PATCH, DELETE operations - [OperationType.Get] = new OpenApiOperation() - { - Description = "Returns an entity.", - Tags = tags, - Responses = new OpenApiResponses() - { - ["200"] = CreateResponse(description: "OK", responseObjectSchemaName: entityName), - ["400"] = CreateResponse(description: "Bad Request"), - ["401"] = CreateResponse(description: "Unauthorized"), - ["403"] = CreateResponse(description: "Forbidden"), - ["404"] = CreateResponse(description: "Not Found") - } - }, - [OperationType.Put] = new OpenApiOperation() - { - Description = "Replace or Create entity/entities.", - Tags = tags, - Responses = new OpenApiResponses() - { - ["200"] = CreateResponse(description: "OK", responseObjectSchemaName: entityName), - ["201"] = CreateResponse(description: "Created", responseObjectSchemaName: entityName), - ["400"] = CreateResponse(description: "Bad Request"), - ["401"] = CreateResponse(description: "Unauthorized"), - ["403"] = CreateResponse(description: "Forbidden"), - ["404"] = CreateResponse(description: "Not Found") - }, - RequestBody = new OpenApiRequestBody() - { - Content = new Dictionary() - { - { - "application/json", - new() - { - Schema = new OpenApiSchema() - { - // when a post request on entity with PK autogenerated, do not allow pk in post body. - Reference = new OpenApiReference() - { - Type = ReferenceType.Schema, - Id = sourceObjectHasAutogeneratedPK ? $"{entityName}_NoPK" : $"{entityName}" - } - } - } - } - }, - // This should be conditioned on whether the table's columns either - // autogenerated - // nullable - // hasDefaultValue - Required = requestBodyRequired - } - }, - [OperationType.Patch] = new OpenApiOperation() - { - Description = "Update or Create entity/entities.", - Tags = tags, - Responses = new OpenApiResponses() - { - ["200"] = CreateResponse(description: "OK", responseObjectSchemaName: entityName), - ["201"] = CreateResponse(description: "Created", responseObjectSchemaName: entityName), - ["400"] = CreateResponse(description: "Bad Request"), - ["401"] = CreateResponse(description: "Unauthorized"), - ["403"] = CreateResponse(description: "Forbidden"), - ["404"] = CreateResponse(description: "Not Found") - }, - RequestBody = new OpenApiRequestBody() - { - Content = new Dictionary() - { - { - "application/json", - new() - { - Schema = new OpenApiSchema() - { - // when a post request on entity with PK autogenerated, do not allow pk in post body. - Reference = new OpenApiReference() - { - Type = ReferenceType.Schema, - Id = sourceObjectHasAutogeneratedPK ? $"{entityName}_NoPK" : $"{entityName}" - } - } - } - } - }, - // This should be conditioned on whether the table's columns either - // autogenerated - // nullable - // hasDefaultValue - Required = requestBodyRequired - } - }, - [OperationType.Delete] = new OpenApiOperation() - { - Description = "Delete entity/entities.", - Tags = tags, - Responses = new OpenApiResponses() - { - ["200"] = CreateResponse(description: "OK", responseObjectSchemaName: entityName), - ["204"] = CreateResponse(description: "No Content", responseObjectSchemaName: entityName), - ["400"] = CreateResponse(description: "Bad Request"), - ["401"] = CreateResponse(description: "Unauthorized"), - ["403"] = CreateResponse(description: "Forbidden"), - ["404"] = CreateResponse(description: "Not Found") - } - } - }, - Parameters = parameters + sourceObjectHasAutogeneratedPK = true; } - ); + } + + return sourceObjectHasAutogeneratedPK; } /// - /// Evaluates a table's columns to determine whethere + /// Evaluates a database object's fields to determine whether a request body is required. + /// A body is required when any one field + /// - is auto generated + /// - has a default value + /// - is nullable /// - /// - /// - public bool IsRequestBodyRequired(SourceDefinition sourceDef) + /// Database object's source metadata. + /// True, when a body should be generated. Otherwise, false. + private static bool IsRequestBodyRequired(SourceDefinition sourceDef) { foreach (KeyValuePair columnMetadata in sourceDef.Columns) { - // Primary Key Check -> Body shouldn't have primary key if it is autogenerated. - if (sourceDef.PrimaryKey.Contains(columnMetadata.Key) && columnMetadata.Value.IsAutoGenerated) - { - continue; - } - // The presence of a non-PK column which does not have any of the following properties // results in the body being required. if (columnMetadata.Value.HasDefault || columnMetadata.Value.IsNullable || columnMetadata.Value.IsAutoGenerated) @@ -425,10 +415,11 @@ public bool IsRequestBodyRequired(SourceDefinition sourceDef) /// /// Name of the entity. /// Returns the REST path name for the provided entity. - public string GetEntityRestPath(string entityName) + private string GetEntityRestPath(string entityName) { - object? entityRestSettings = _runtimeConfig.Entities[entityName].GetRestEnabledOrPathSettings(); string entityRestPath = entityName; + object? entityRestSettings = _runtimeConfig.Entities[entityName].GetRestEnabledOrPathSettings(); + if (entityRestSettings is not null && entityRestSettings is string) { entityRestPath = (string)entityRestSettings; @@ -443,91 +434,97 @@ public string GetEntityRestPath(string entityName) return entityRestPath; } - public OpenApiComponents BuildComponents() - { - return new OpenApiComponents() - { - Schemas = BuildComponentSchemas() - }; - } - - public OpenApiResponse CreateResponse(string description, string? responseObjectSchemaName = null) + /// + /// Creates the base OpenApiResponse object common to all requests where + /// responses are of type "application/json". + /// + /// HTTP Response Code Name: OK, Created, BadRequest, etc. + /// Schema used to represent response records. + /// Null when an example (such as error codes) adds redundant verbosity. + /// Base OpenApiResponse object + private static OpenApiResponse CreateOpenApiResponse(string description, string? responseObjectSchemaName = null) { OpenApiResponse response = new() { Description = description }; - // No entityname means there no example response object schema should be included. + // No entityname means no response object schema should be included. // the entityname references the schema of the response object. if (!string.IsNullOrEmpty(responseObjectSchemaName)) { - response.Content = new Dictionary() + Dictionary contentDictionary = new() { { - "application/json", - new() - { - Schema = new OpenApiSchema() - { - Properties = new Dictionary() - { - { - "value", - new OpenApiSchema() - { - Type = "array", - Items = new OpenApiSchema() - { - Reference = new OpenApiReference() - { - Type = ReferenceType.Schema, - Id = $"{responseObjectSchemaName}" - } - } - } - } - } - } - } + JSON_MEDIA_TYPE, + CreateResponseContainer(responseObjectSchemaName) } }; + response.Content = contentDictionary; } return response; } - public OpenApiMediaType GetResponsePayload(string entityName) + /// + /// Creates the OpenAPI description of the response payload, excluding the result records: + /// { + /// "value": [ + /// { + /// "resultProperty": resultPropertyValue + /// } + /// ] + /// } + /// + /// Schema name of response payload. + /// The base response object container. + private static OpenApiMediaType CreateResponseContainer(string responseObjectSchemaName) { + // schema for the response's collection of result records + OpenApiSchema resultCollectionSchema = new() + { + Reference = new OpenApiReference() + { + Type = ReferenceType.Schema, + Id = $"{responseObjectSchemaName}" + } + }; + + // Schema for the response's root property "value" + OpenApiSchema responseRootSchema = new() + { + Type = RESPONSE_ARRAY_PROPERTY, + Items = resultCollectionSchema + }; + + Dictionary responseBodyProperties = new() + { + { + RESPONSE_VALUE_PROPERTY, + responseRootSchema + } + }; + OpenApiMediaType responsePayload = new() { - Schema = new OpenApiSchema() + Schema = new() { - Properties = new Dictionary() - { - { - "value", - new OpenApiSchema() - { - Type = "array", - Items = new OpenApiSchema() - { - Reference = new OpenApiReference() - { - Type = ReferenceType.Schema, - Id = $"{entityName}" - } - } - } - } - } + Properties = responseBodyProperties } }; return responsePayload; } - public Dictionary BuildComponentSchemas() + /// + /// Builds the schema objects for all entities present in the runtime configuration. + /// Two schemas per entity are created: + /// 1) {EntityName} -> Primary keys present in schema, used for request bodies (excluding GET) and all response bodies. + /// 2) {EntityName}_NoPK -> No primary keys present in schema, used for POST requests where PK is autogenerated. + /// Schema objects can be referenced elsewhere in the OpenAPI document with the intent to reduce document verbosity. + /// + /// Collection of schemas for entities defined in the runtime configuration. + private Dictionary CreateComponentSchemas() { Dictionary schemas = new(); @@ -541,7 +538,6 @@ public Dictionary BuildComponentSchemas() // create component for entity with no PK // get list of columns - primary key columns then optimize - foreach (string primaryKeyColumn in sourceDefinition.PrimaryKey) { columns.Remove(primaryKeyColumn); @@ -559,19 +555,20 @@ public Dictionary BuildComponentSchemas() } /// - /// Input needs entity fields and field data type metadata - /// Should this have conditional for creating component with PK field? or should that be handled before - /// and only pass in a field list here to generalize? - /// ex + /// Creates the schema object for an entity. /// /// Name of the entity. /// List of mapped (alias) field names. - /// - public OpenApiSchema CreateComponentSchema(string entityName, List fields) + /// Raised when an entity's database metadata can't be found. + /// Entity's OpenApiSchema representation. + private OpenApiSchema CreateComponentSchema(string entityName, List fields) { if (!_metadataProvider.EntityToDatabaseObject.TryGetValue(entityName, out DatabaseObject? dbObject) || dbObject is null) { - throw new DataApiBuilderException(message: "oops bad entity", statusCode: System.Net.HttpStatusCode.InternalServerError, subStatusCode: DataApiBuilderException.SubStatusCodes.OpenApiDocumentAlreadyExists); + throw new DataApiBuilderException( + message: "Entity's database object metadata not found.", + statusCode: HttpStatusCode.InternalServerError, + subStatusCode: DataApiBuilderException.SubStatusCodes.OpenApiDocumentAlreadyExists); } Dictionary properties = new(); @@ -603,9 +600,35 @@ public OpenApiSchema CreateComponentSchema(string entityName, List field return schema; } - public JsonValueKind SystemTypeToJsonValueKind(Type type) + /// + /// Creates the default collection of responses for all requests in the OpenAPI + /// description document. + /// + /// Collection of default responses (400, 401, 403, 404). + private static OpenApiResponses CreateDefaultOpenApiResponses() { - return type.Name switch + OpenApiResponses defaultResponses = new() + { + { HttpStatusCode.BadRequest.ToString(), CreateOpenApiResponse(description: nameof(HttpStatusCode.BadRequest)) }, + { HttpStatusCode.Unauthorized.ToString(), CreateOpenApiResponse(description: nameof(HttpStatusCode.Unauthorized)) }, + { HttpStatusCode.Forbidden.ToString(), CreateOpenApiResponse(description: nameof(HttpStatusCode.Forbidden)) }, + { HttpStatusCode.NotFound.ToString(), CreateOpenApiResponse(description: nameof(HttpStatusCode.NotFound)) } + }; + + return defaultResponses; + } + + /// + /// Converts the CLR type to System.Text.Json's JsonValueKind + /// to meet the data type requirement set by the OpenAPI specification. + /// The value returned is formatted for the OpenAPI spec "type" property. + /// + /// CLR type + /// + /// Formatted JSON type name in lower case: e.g. number, string, boolean, etc. + private static string SystemTypeToJsonValueKind(Type type) + { + JsonValueKind openApiTypeName = type.Name switch { "String" => JsonValueKind.String, "Guid" => JsonValueKind.String, @@ -616,45 +639,16 @@ public JsonValueKind SystemTypeToJsonValueKind(Type type) "Single" => JsonValueKind.Number, "Double" => JsonValueKind.Number, "Decimal" => JsonValueKind.Number, + "Float" => JsonValueKind.Number, "Boolean" => JsonValueKind.True, "DateTime" => JsonValueKind.String, "DateTimeOffset" => JsonValueKind.String, "Byte[]" => JsonValueKind.String, - _ => throw new DataApiBuilderException( - message: $"Column type {type} not handled by case. Please add a case resolving {type} to the appropriate GraphQL type", - statusCode: HttpStatusCode.InternalServerError, - subStatusCode: DataApiBuilderException.SubStatusCodes.GraphQLMapping) + _ => JsonValueKind.Undefined }; - } - - /// - /// Returns schema object representing an Entity's non-primary key fields. - /// - /// - public OpenApiSchema EntityToSchemaObject() - { - return new OpenApiSchema(); - } - /// - /// Returns schema object representing Entity including primary key and non-primary key fields. - /// - /// - public OpenApiSchema FullEntityToSchemaObject() - { - return new OpenApiSchema(); - } - - /// - /// Returns collection representing an entity's field metadata - /// Key: field name - /// Value: OpenApiSchema describing the (value) Type and (value) Format of the field. - /// - /// - public Dictionary BuildComponentProperties() - { - Dictionary fieldMetadata = new(); - return fieldMetadata; + string formattedOpenApiTypeName = openApiTypeName.ToString().ToLower(); + return formattedOpenApiTypeName; } } } From c7831061007977d48be1fa6ebf679948cead4dbc Mon Sep 17 00:00:00 2001 From: Sean Leonard Date: Thu, 27 Apr 2023 17:22:46 -0700 Subject: [PATCH 08/30] updated response codes and formatting of number in openapi doc. --- src/Service/Services/OpenApiDocumentor.cs | 31 +++++++++++++---------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/src/Service/Services/OpenApiDocumentor.cs b/src/Service/Services/OpenApiDocumentor.cs index 6fae615a44..0773fabfe0 100644 --- a/src/Service/Services/OpenApiDocumentor.cs +++ b/src/Service/Services/OpenApiDocumentor.cs @@ -198,7 +198,7 @@ private Tuple BuildPath(string entityName, bool include Tags = tags, Responses = new(_defaultOpenApiResponses), }; - getOperation.Responses.Add(HttpStatusCode.OK.ToString(), CreateOpenApiResponse(description: nameof(HttpStatusCode.OK), responseObjectSchemaName: entityName)); + getOperation.Responses.Add(HttpStatusCode.OK.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.OK), responseObjectSchemaName: entityName)); OpenApiOperation putOperation = new() { @@ -207,8 +207,8 @@ private Tuple BuildPath(string entityName, bool include Responses = new(_defaultOpenApiResponses), RequestBody = CreateOpenApiRequestBodyPayload(schemaReferenceId, requestBodyRequired) }; - putOperation.Responses.Add(HttpStatusCode.OK.ToString(), CreateOpenApiResponse(description: nameof(HttpStatusCode.OK), responseObjectSchemaName: entityName)); - putOperation.Responses.Add(HttpStatusCode.Created.ToString(), CreateOpenApiResponse(description: nameof(HttpStatusCode.Created), responseObjectSchemaName: entityName)); + putOperation.Responses.Add(HttpStatusCode.OK.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.OK), responseObjectSchemaName: entityName)); + putOperation.Responses.Add(HttpStatusCode.Created.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.Created), responseObjectSchemaName: entityName)); OpenApiOperation patchOperation = new() { @@ -217,8 +217,8 @@ private Tuple BuildPath(string entityName, bool include Responses = new(_defaultOpenApiResponses), RequestBody = CreateOpenApiRequestBodyPayload(schemaReferenceId, requestBodyRequired) }; - patchOperation.Responses.Add(HttpStatusCode.OK.ToString(), CreateOpenApiResponse(description: nameof(HttpStatusCode.OK), responseObjectSchemaName: entityName)); - patchOperation.Responses.Add(HttpStatusCode.Created.ToString(), CreateOpenApiResponse(description: nameof(HttpStatusCode.Created), responseObjectSchemaName: entityName)); + patchOperation.Responses.Add(HttpStatusCode.OK.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.OK), responseObjectSchemaName: entityName)); + patchOperation.Responses.Add(HttpStatusCode.Created.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.Created), responseObjectSchemaName: entityName)); OpenApiOperation deleteOperation = new() { @@ -227,8 +227,7 @@ private Tuple BuildPath(string entityName, bool include Responses = new(_defaultOpenApiResponses), RequestBody = CreateOpenApiRequestBodyPayload(schemaReferenceId, requestBodyRequired) }; - deleteOperation.Responses.Add(HttpStatusCode.OK.ToString(), CreateOpenApiResponse(description: nameof(HttpStatusCode.OK), responseObjectSchemaName: entityName)); - deleteOperation.Responses.Add(HttpStatusCode.Created.ToString(), CreateOpenApiResponse(description: nameof(HttpStatusCode.Created), responseObjectSchemaName: entityName)); + deleteOperation.Responses.Add(HttpStatusCode.NoContent.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.NoContent))); OpenApiPathItem openApiPathItem = new() { @@ -254,7 +253,7 @@ private Tuple BuildPath(string entityName, bool include Responses = new(_defaultOpenApiResponses), RequestBody = CreateOpenApiRequestBodyPayload(schemaReferenceId, requestBodyRequired) }; - getAllOperation.Responses.Add(HttpStatusCode.OK.ToString(), CreateOpenApiResponse(description: nameof(HttpStatusCode.OK), responseObjectSchemaName: entityName)); + getAllOperation.Responses.Add(HttpStatusCode.OK.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.OK), responseObjectSchemaName: entityName)); OpenApiOperation postOperation = new() { @@ -263,8 +262,8 @@ private Tuple BuildPath(string entityName, bool include Responses = new(_defaultOpenApiResponses), RequestBody = CreateOpenApiRequestBodyPayload(schemaReferenceId, requestBodyRequired) }; - postOperation.Responses.Add(HttpStatusCode.Created.ToString(), CreateOpenApiResponse(description: nameof(HttpStatusCode.Created), responseObjectSchemaName: entityName)); - postOperation.Responses.Add(HttpStatusCode.Conflict.ToString(), CreateOpenApiResponse(description: nameof(HttpStatusCode.Conflict), responseObjectSchemaName: entityName)); + postOperation.Responses.Add(HttpStatusCode.Created.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.Created), responseObjectSchemaName: entityName)); + postOperation.Responses.Add(HttpStatusCode.Conflict.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.Conflict))); OpenApiPathItem openApiPathItem = new() { @@ -603,16 +602,20 @@ private OpenApiSchema CreateComponentSchema(string entityName, List fiel /// /// Creates the default collection of responses for all requests in the OpenAPI /// description document. + /// The OpenApiResponses dictionary key represents the integer value of the HttpStatusCode, + /// which is returned when using Enum.ToString("D"). + /// The "D" format specified "displays the enumeration entry as an integer value in the shortest representation possible." /// + /// /// Collection of default responses (400, 401, 403, 404). private static OpenApiResponses CreateDefaultOpenApiResponses() { OpenApiResponses defaultResponses = new() { - { HttpStatusCode.BadRequest.ToString(), CreateOpenApiResponse(description: nameof(HttpStatusCode.BadRequest)) }, - { HttpStatusCode.Unauthorized.ToString(), CreateOpenApiResponse(description: nameof(HttpStatusCode.Unauthorized)) }, - { HttpStatusCode.Forbidden.ToString(), CreateOpenApiResponse(description: nameof(HttpStatusCode.Forbidden)) }, - { HttpStatusCode.NotFound.ToString(), CreateOpenApiResponse(description: nameof(HttpStatusCode.NotFound)) } + { HttpStatusCode.BadRequest.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.BadRequest)) }, + { HttpStatusCode.Unauthorized.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.Unauthorized)) }, + { HttpStatusCode.Forbidden.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.Forbidden)) }, + { HttpStatusCode.NotFound.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.NotFound)) } }; return defaultResponses; From 81ceda1e567445b5b520649bf120414abff8b46a Mon Sep 17 00:00:00 2001 From: Sean Leonard Date: Thu, 27 Apr 2023 17:29:11 -0700 Subject: [PATCH 09/30] updated openapicontroller route and updated swaggerUI uri to point to new route. --- src/Service/Controllers/OpenApiController.cs | 4 +--- src/Service/Startup.cs | 3 +-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/Service/Controllers/OpenApiController.cs b/src/Service/Controllers/OpenApiController.cs index 55b7d29d8a..1165ad7070 100644 --- a/src/Service/Controllers/OpenApiController.cs +++ b/src/Service/Controllers/OpenApiController.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System; -using System.Net; using Azure.DataApiBuilder.Service.Exceptions; using Azure.DataApiBuilder.Service.Services; using Microsoft.AspNetCore.Mvc; @@ -12,7 +10,7 @@ namespace Azure.DataApiBuilder.Service.Controllers /// /// /// - [Route("api/[controller]")] + [Route("[controller]")] [ApiController] public class OpenApiController : ControllerBase { diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index 7516c81ac9..894cd8a1a4 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -357,10 +357,9 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, RuntimeC } app.UseStaticFiles(); - //app.UseSwagger(); app.UseSwaggerUI(c => { - c.SwaggerEndpoint("/api/openapi", "DataAPIbuilder-OpenAPI-Alpha"); + c.SwaggerEndpoint("/openapi", "DataAPIbuilder-OpenAPI-Alpha"); } ); From accb50ef7d9dcbe23bb9720b2a8501189bdf54da Mon Sep 17 00:00:00 2001 From: Sean Leonard Date: Thu, 27 Apr 2023 17:49:54 -0700 Subject: [PATCH 10/30] Remove swagger package, keep swaggerui. --- src/Directory.Packages.props | 3 +-- src/Service/Azure.DataApiBuilder.Service.csproj | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index fbaf8e2383..2935063848 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -33,7 +33,6 @@ - @@ -41,4 +40,4 @@ - \ No newline at end of file + diff --git a/src/Service/Azure.DataApiBuilder.Service.csproj b/src/Service/Azure.DataApiBuilder.Service.csproj index ac5e652154..0306c9b4b6 100644 --- a/src/Service/Azure.DataApiBuilder.Service.csproj +++ b/src/Service/Azure.DataApiBuilder.Service.csproj @@ -76,7 +76,6 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - From 63f0b956d664a1d039a947cd9c54add0a03e205e Mon Sep 17 00:00:00 2001 From: Sean Leonard Date: Thu, 27 Apr 2023 18:05:50 -0700 Subject: [PATCH 11/30] added constants and new suberrorcode. --- src/Config/DataApiBuilderException.cs | 6 +++++- src/Service/Services/OpenApiDocumentor.cs | 18 +++++++++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/Config/DataApiBuilderException.cs b/src/Config/DataApiBuilderException.cs index 6349f23950..fde6a0407f 100644 --- a/src/Config/DataApiBuilderException.cs +++ b/src/Config/DataApiBuilderException.cs @@ -87,7 +87,11 @@ public enum SubStatusCodes /// /// Attempting to generate OpenAPI document failed. /// - OpenApiDocumentGenerationFailure + OpenApiDocumentGenerationFailure, + /// + /// Global REST endpoint disabled in runtime configuration. + /// + GlobalRestEndpointDisabled } public HttpStatusCode StatusCode { get; } diff --git a/src/Service/Services/OpenApiDocumentor.cs b/src/Service/Services/OpenApiDocumentor.cs index 0773fabfe0..5aff5703d8 100644 --- a/src/Service/Services/OpenApiDocumentor.cs +++ b/src/Service/Services/OpenApiDocumentor.cs @@ -30,6 +30,10 @@ public class OpenApiDocumentor : IOpenApiDocumentor private OpenApiResponses _defaultOpenApiResponses; private OpenApiDocument? _openApiDocument; + private const string DOCUMENTOR_VERSION = "PREVIEW"; + private const string DOCUMENTOR_UI_TITLE = "Data API builder - REST Endpoint"; + private const string DOCUMENT_ALREADY_GENERATED_ERROR = "OpenAPI description document already generated."; + private const string DOCUMENT_CREATION_UNSUPPORTED_ERROR = "OpenAPI description document can't be created when the REST endpoint is disabled globally."; private const string JSON_MEDIA_TYPE = "application/json"; private const string GETALL_DESCRIPTION = "Returns entities."; private const string GETONE_DESCRIPTION = "Returns an entity."; @@ -88,11 +92,19 @@ public void CreateDocument() if (_openApiDocument is not null) { throw new DataApiBuilderException( - message: "OpenAPI description document already generated.", + message: DOCUMENT_ALREADY_GENERATED_ERROR, statusCode: HttpStatusCode.Conflict, subStatusCode: DataApiBuilderException.SubStatusCodes.OpenApiDocumentAlreadyExists); } + if (!_runtimeConfig.RestGlobalSettings.Enabled) + { + throw new DataApiBuilderException( + message: DOCUMENT_CREATION_UNSUPPORTED_ERROR, + statusCode: HttpStatusCode.NotFound, + subStatusCode: DataApiBuilderException.SubStatusCodes.GlobalRestEndpointDisabled); + } + try { OpenApiComponents components = new() @@ -104,8 +116,8 @@ public void CreateDocument() { Info = new OpenApiInfo { - Version = "PREVIEW", - Title = "Data API builder - REST Endpoint", + Version = DOCUMENTOR_VERSION, + Title = DOCUMENTOR_UI_TITLE, }, Servers = new List { From 2c2565366770725610f62850611b3a1bd5ab61ab Mon Sep 17 00:00:00 2001 From: Sean Leonard Date: Fri, 28 Apr 2023 16:17:36 -0700 Subject: [PATCH 12/30] updated media type set as application/json for GET endpoint. updated return http codes --- src/Service/Controllers/OpenApiController.cs | 42 ++++++++++++++++---- src/Service/Services/OpenApiDocumentor.cs | 26 ++++++------ src/Service/Startup.cs | 17 ++++---- 3 files changed, 57 insertions(+), 28 deletions(-) diff --git a/src/Service/Controllers/OpenApiController.cs b/src/Service/Controllers/OpenApiController.cs index 1165ad7070..f19d3f9ad7 100644 --- a/src/Service/Controllers/OpenApiController.cs +++ b/src/Service/Controllers/OpenApiController.cs @@ -1,20 +1,26 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Net.Mime; using Azure.DataApiBuilder.Service.Exceptions; using Azure.DataApiBuilder.Service.Services; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; namespace Azure.DataApiBuilder.Service.Controllers { /// - /// + /// Facilitate access to a created OpenAPI description document or trigger the creation of + /// the OpenAPI description document. /// [Route("[controller]")] [ApiController] public class OpenApiController : ControllerBase { - private IOpenApiDocumentor _apiDocumentor; + /// + /// OpenAPI description document creation service. + /// + private readonly IOpenApiDocumentor _apiDocumentor; public OpenApiController(IOpenApiDocumentor openApiDocumentor) { @@ -22,20 +28,40 @@ public OpenApiController(IOpenApiDocumentor openApiDocumentor) } /// - /// + /// Get the created OpenAPI description document created to represent the possible + /// paths and operations on the DAB engine's REST endpoint. /// - /// + /// + /// HTTP 200 - Open API description document. + /// HTTP 404 - OpenAPI description document not available since it hasn't been created + /// or failed to be created. [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public IActionResult Get() { - return _apiDocumentor.TryGetDocument(out string? document) ? Ok(document) : NotFound(); + if (_apiDocumentor.TryGetDocument(out string? document)) + { + return Content(document, MediaTypeNames.Application.Json); + } + + return NotFound(); } /// - /// + /// Trigger the creation of the OpenAPI description document if it wasn't already created + /// using this method or created during engine startup. /// - /// + /// + /// HTTP 201 - OpenAPI description document if creation was triggered + /// HTTP 405 - Document creation method not allowed, global REST endpoint disabled in runtime config. + /// HTTP 409 - Document already created + /// HTTP 500 - Document creation failed. [HttpPost] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status405MethodNotAllowed)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] public IActionResult Post() { try @@ -44,7 +70,7 @@ public IActionResult Post() if (_apiDocumentor.TryGetDocument(out string? document)) { - return new CreatedResult(location:"/openapi" ,value: document); + return new CreatedResult(location: "/openapi", value: document); } return NotFound(); diff --git a/src/Service/Services/OpenApiDocumentor.cs b/src/Service/Services/OpenApiDocumentor.cs index 5aff5703d8..c40c2ba737 100644 --- a/src/Service/Services/OpenApiDocumentor.cs +++ b/src/Service/Services/OpenApiDocumentor.cs @@ -8,6 +8,7 @@ using System.IO; using System.Linq; using System.Net; +using System.Net.Mime; using System.Text; using System.Text.Json; using Azure.DataApiBuilder.Config; @@ -34,7 +35,6 @@ public class OpenApiDocumentor : IOpenApiDocumentor private const string DOCUMENTOR_UI_TITLE = "Data API builder - REST Endpoint"; private const string DOCUMENT_ALREADY_GENERATED_ERROR = "OpenAPI description document already generated."; private const string DOCUMENT_CREATION_UNSUPPORTED_ERROR = "OpenAPI description document can't be created when the REST endpoint is disabled globally."; - private const string JSON_MEDIA_TYPE = "application/json"; private const string GETALL_DESCRIPTION = "Returns entities."; private const string GETONE_DESCRIPTION = "Returns an entity."; private const string POST_DESCRIPTION = "Create entity."; @@ -101,12 +101,13 @@ public void CreateDocument() { throw new DataApiBuilderException( message: DOCUMENT_CREATION_UNSUPPORTED_ERROR, - statusCode: HttpStatusCode.NotFound, + statusCode: HttpStatusCode.MethodNotAllowed, subStatusCode: DataApiBuilderException.SubStatusCodes.GlobalRestEndpointDisabled); } try { + string restEndpointPath = _runtimeConfig.RestGlobalSettings.Path; OpenApiComponents components = new() { Schemas = CreateComponentSchemas() @@ -121,14 +122,14 @@ public void CreateDocument() }, Servers = new List { - new OpenApiServer { Url = "https://localhost:5000/api/openapi" } + new OpenApiServer { Url = $"{restEndpointPath}" } }, Paths = BuildPaths(), Components = components }; _openApiDocument = doc; } - catch(Exception ex) + catch (Exception ex) { throw new DataApiBuilderException( message: "OpenAPI description document generation failed.", @@ -197,14 +198,14 @@ private Tuple BuildPath(string entityName, bool include { openApiTag }; - + if (includePrimaryKeyPathComponent) { Tuple> pkComponents = CreatePrimaryKeyPathComponentAndParameters(entityName); string pkPathComponents = pkComponents.Item1; string fullPathComponent = entityBasePathComponent + pkPathComponents; - OpenApiOperation getOperation = new() + OpenApiOperation getOperation = new() { Description = GETONE_DESCRIPTION, Tags = tags, @@ -263,7 +264,6 @@ private Tuple BuildPath(string entityName, bool include Description = GETALL_DESCRIPTION, Tags = tags, Responses = new(_defaultOpenApiResponses), - RequestBody = CreateOpenApiRequestBodyPayload(schemaReferenceId, requestBodyRequired) }; getAllOperation.Responses.Add(HttpStatusCode.OK.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.OK), responseObjectSchemaName: entityName)); @@ -306,7 +306,7 @@ private static OpenApiRequestBody CreateOpenApiRequestBodyPayload(string schemaR Content = new Dictionary() { { - JSON_MEDIA_TYPE, + MediaTypeNames.Application.Json, new() { Schema = new OpenApiSchema() @@ -467,11 +467,11 @@ private static OpenApiResponse CreateOpenApiResponse(string description, string? Dictionary contentDictionary = new() { { - JSON_MEDIA_TYPE, + MediaTypeNames.Application.Json, CreateResponseContainer(responseObjectSchemaName) } }; - response.Content = contentDictionary; + response.Content = contentDictionary; } return response; @@ -577,12 +577,12 @@ private OpenApiSchema CreateComponentSchema(string entityName, List fiel if (!_metadataProvider.EntityToDatabaseObject.TryGetValue(entityName, out DatabaseObject? dbObject) || dbObject is null) { throw new DataApiBuilderException( - message: "Entity's database object metadata not found.", - statusCode: HttpStatusCode.InternalServerError, + message: "Entity's database object metadata not found.", + statusCode: HttpStatusCode.InternalServerError, subStatusCode: DataApiBuilderException.SubStatusCodes.OpenApiDocumentAlreadyExists); } - Dictionary properties = new(); + Dictionary properties = new(); foreach (string field in fields) { if (_metadataProvider.TryGetBackingColumn(entityName, field, out string? backingColumnValue) && !string.IsNullOrEmpty(backingColumnValue)) diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index 894cd8a1a4..c8419f963b 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -356,13 +356,6 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, RuntimeC app.UseHttpsRedirection(); } - app.UseStaticFiles(); - app.UseSwaggerUI(c => - { - c.SwaggerEndpoint("/openapi", "DataAPIbuilder-OpenAPI-Alpha"); - } - ); - // URL Rewrite middleware MUST be called prior to UseRouting(). // https://andrewlock.net/understanding-pathbase-in-aspnetcore/#placing-usepathbase-in-the-correct-location app.UseCorrelationIdMiddleware(); @@ -378,6 +371,16 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, RuntimeC ConfigureCors(CORSPolicyBuilder, corsConfig); }); } + // SwaggerUI visualization of the OpenAPI description document is only available + // in developer mode in alignment with the restriction placed on ChilliCream's BananaCakePop IDE. + if (runtimeConfigProvider.IsDeveloperMode() || env.IsDevelopment()) + { + //app.UseStaticFiles(); + app.UseSwaggerUI(c => + { + c.SwaggerEndpoint("/openapi", "DataAPIbuilder-OpenAPI-Alpha"); + }); + } app.Use(async (context, next) => { From 93bebc84999da39f6a169c1a3e63ca533abe123b Mon Sep 17 00:00:00 2001 From: Sean Leonard Date: Fri, 28 Apr 2023 16:47:34 -0700 Subject: [PATCH 13/30] prevent entities from being openapi documented if they explicitly disabled REST --- src/Service/Services/OpenApiDocumentor.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/Service/Services/OpenApiDocumentor.cs b/src/Service/Services/OpenApiDocumentor.cs index c40c2ba737..18826217c8 100644 --- a/src/Service/Services/OpenApiDocumentor.cs +++ b/src/Service/Services/OpenApiDocumentor.cs @@ -154,6 +154,19 @@ private OpenApiPaths BuildPaths() foreach (string entityName in _metadataProvider.EntityToDatabaseObject.Keys.ToList()) { + // Entities which disable their REST endpoint must not be included in + // the OpenAPI description document. + if (_runtimeConfig.Entities.TryGetValue(entityName, out Entity? entity) && entity is not null) + { + if (entity.GetRestEnabledOrPathSettings() is bool restEnabled) + { + if (!restEnabled) + { + continue; + } + } + } + // Routes including primary key Tuple path = BuildPath(entityName, includePrimaryKeyPathComponent: false); pathsCollection.Add(path.Item1, path.Item2); From 3489ff9725c65e9dab1124a709f1df4f02bb27d0 Mon Sep 17 00:00:00 2001 From: Sean Leonard Date: Wed, 3 May 2023 01:02:26 -0700 Subject: [PATCH 14/30] Consolidated code paths, reduced code verbosity/duplication. Comments. added tests. --- .../Configuration/ConfigurationTests.cs | 256 +++++++++++++++++- .../OpenApiDocumentorConstants.cs | 15 + src/Service/Controllers/OpenApiController.cs | 2 +- src/Service/Services/IOpenApiDocumentor.cs | 13 - .../Services/OpenAPI/IOpenApiDocumentor.cs | 31 +++ src/Service/Services/OpenAPI/JsonDataType.cs | 42 +++ .../{ => OpenAPI}/OpenApiDocumentor.cs | 90 +++--- src/Service/Startup.cs | 4 +- 8 files changed, 399 insertions(+), 54 deletions(-) create mode 100644 src/Service.Tests/OpenApiDocumentor/OpenApiDocumentorConstants.cs delete mode 100644 src/Service/Services/IOpenApiDocumentor.cs create mode 100644 src/Service/Services/OpenAPI/IOpenApiDocumentor.cs create mode 100644 src/Service/Services/OpenAPI/JsonDataType.cs rename src/Service/Services/{ => OpenAPI}/OpenApiDocumentor.cs (90%) diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index c715da1894..2107fd57be 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -21,6 +21,7 @@ using Azure.DataApiBuilder.Service.Resolvers; using Azure.DataApiBuilder.Service.Services; using Azure.DataApiBuilder.Service.Services.MetadataProviders; +using Azure.DataApiBuilder.Service.Services.OpenAPI; using Azure.DataApiBuilder.Service.Tests.Authorization; using Azure.DataApiBuilder.Service.Tests.SqlTests; using HotChocolate; @@ -48,6 +49,7 @@ public class ConfigurationTests private const string POST_STARTUP_CONFIG_ENTITY_SOURCE = "books"; private const string POST_STARTUP_CONFIG_ROLE = "PostStartupConfigRole"; private const string COSMOS_DATABASE_NAME = "config_db"; + private const int RETRY_COUNT = 5; private const int RETRY_WAIT_SECONDS = 1; @@ -1388,6 +1390,248 @@ public void TestInvalidDatabaseColumnNameHandling( } } + + /// + /// Test different graphql endpoints in different host modes + /// when accessed interactively via browser. + /// + /// The endpoint route + /// The mode in which the service is executing. + /// Expected Status Code. + /// The expected phrase in the response body. + [DataTestMethod] + [TestCategory(TestCategory.MSSQL)] + [DataRow("/swagger", HostModeType.Development, HttpStatusCode.OK, "", DisplayName = "SwaggerUI enabled in development mode.")] + [DataRow("/swagger", HostModeType.Production, HttpStatusCode.BadRequest, "", DisplayName = "SwaggerUI disabled in production mode.")] + public async Task OpenApi_InteractiveSwaggerUI( + string endpoint, + HostModeType hostModeType, + HttpStatusCode expectedStatusCode, + string expectedContent = "") + { + Dictionary settings = new() + { + { GlobalSettingsType.Host, JsonSerializer.SerializeToElement(new HostGlobalSettings(){ Mode = hostModeType}) }, + { GlobalSettingsType.GraphQL, JsonSerializer.SerializeToElement(new GraphQLGlobalSettings(){ Enabled = true }) }, + { GlobalSettingsType.Rest, JsonSerializer.SerializeToElement(new RestGlobalSettings(){ Enabled = true }) } + }; + + DataSource dataSource = new(DatabaseType.mssql) + { + ConnectionString = GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL) + }; + + RuntimeConfig configuration = InitMinimalRuntimeConfig(globalSettings: settings, dataSource: dataSource); + const string CUSTOM_CONFIG = "custom-config.json"; + File.WriteAllText( + CUSTOM_CONFIG, + JsonSerializer.Serialize(configuration, RuntimeConfig.SerializerOptions)); + + string[] args = new[] + { + $"--ConfigFileName={CUSTOM_CONFIG}" + }; + + TestServer server = new(Program.CreateWebHostBuilder(args)); + + HttpClient client = server.CreateClient(); + HttpRequestMessage request = new(HttpMethod.Get, endpoint); + + // Adding the following headers simulates an interactive browser request. + request.Headers.Add("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36"); + request.Headers.Add("accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"); + + HttpResponseMessage response = await client.SendAsync(request); + Assert.AreEqual(expectedStatusCode, response.StatusCode); + string actualBody = await response.Content.ReadAsStringAsync(); + Assert.IsTrue(actualBody.Contains(expectedContent)); + } + + /// + /// Validates the OpenAPI documentor behavior when enabling and disabling the global REST endpoint + /// for the DAB engine. + /// Global REST enabled: + /// - POST to /openapi triggers OpenAPI document creation but should fail with 405 Method Not Allowed. + /// - GET to /openapi returns the created OpenAPI document, but should fail with 404 Not Found. + /// Global REST disabled: + /// - POST to /openapi triggers OpenAPI document creation fails with 409 Conflict because the + /// document was already created during engine startup. + /// - GET to /openapi returns the created OpenAPI document and succeeds with 200 OK. + /// + [DataTestMethod] + [DataRow(true, false, DisplayName = "Global REST endpoint enabled - conflict creating OpenAPI doc and successful retrieval")] + [DataRow(false, true, DisplayName = "Global REST endpoint disabled - failure to create and retrieve OpenAPI doc")] + [TestCategory(TestCategory.MSSQL)] + public async Task OpenApi_GlobalEntityRestPath(bool globalRestEnabled, bool expectsError) + { + Dictionary settings = new() + { + { GlobalSettingsType.GraphQL, JsonSerializer.SerializeToElement(new GraphQLGlobalSettings(){ Enabled = true }) }, + { GlobalSettingsType.Rest, JsonSerializer.SerializeToElement(new RestGlobalSettings(){ Enabled = globalRestEnabled }) } + }; + + DataSource dataSource = new(DatabaseType.mssql) + { + ConnectionString = GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL) + }; + + RuntimeConfig configuration = InitMinimalRuntimeConfig(globalSettings: settings, dataSource: dataSource); + const string CUSTOM_CONFIG = "custom-config.json"; + File.WriteAllText( + CUSTOM_CONFIG, + JsonSerializer.Serialize(configuration, RuntimeConfig.SerializerOptions)); + + string[] args = new[] + { + $"--ConfigFileName={CUSTOM_CONFIG}" + }; + + using (TestServer server = new(Program.CreateWebHostBuilder(args))) + using (HttpClient client = server.CreateClient()) + { + // Setup and send request + HttpRequestMessage createOpenApiDocumentRequest = new(HttpMethod.Post, "/openapi"); + HttpResponseMessage response = await client.SendAsync(createOpenApiDocumentRequest); + + // Parse response metadata + string responseBody = await response.Content.ReadAsStringAsync(); + + if (expectsError) + { + Dictionary actualAsDict = JsonSerializer.Deserialize>(responseBody); + JsonElement actualErrorObj = actualAsDict["error"]; + string responseBodyStatusCode = actualErrorObj.GetProperty("status").ToString(); + string responseBodySubStatusCode = actualErrorObj.GetProperty("code").ToString(); + string responseBodyMessage = actualErrorObj.GetProperty("message").ToString(); + + // Validate response + Assert.AreEqual(HttpStatusCode.MethodNotAllowed, response.StatusCode); + Assert.AreEqual(OpenApiDocumentor.DOCUMENT_CREATION_UNSUPPORTED_ERROR, responseBodyMessage); + Assert.AreEqual(((int)HttpStatusCode.MethodNotAllowed).ToString(), responseBodyStatusCode); + Assert.AreEqual(DataApiBuilderException.SubStatusCodes.GlobalRestEndpointDisabled.ToString(), responseBodySubStatusCode); + } + else + { + Assert.AreEqual(HttpStatusCode.Conflict, response.StatusCode); + } + + // Setup and send request + HttpRequestMessage readOpenApiDocumentRequest = new(HttpMethod.Get, "/openapi"); + response = await client.SendAsync(readOpenApiDocumentRequest); + responseBody = await response.Content.ReadAsStringAsync(); + Dictionary responseProperties = JsonSerializer.Deserialize>(responseBody); + + // Validate response + if (expectsError) + { + Assert.AreEqual(HttpStatusCode.NotFound, response.StatusCode); + } + else + { + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + ValidateOpenApiDocTopLevelPropertiesExist(responseProperties); + } + } + } + + /// + /// Validates the behavior of the OpenApiDocumentor when the runtime config has entities with + /// REST endpoint enabled and disabled. + /// Enabled -> path should be created + /// Disabled -> path not created and is excluded from OpenApi document. + /// + [TestCategory(TestCategory.MSSQL)] + [TestMethod] + public async Task OpenApi_EntityLevelRestEndpoint() + { + Dictionary settings = new() + { + { GlobalSettingsType.GraphQL, JsonSerializer.SerializeToElement(new GraphQLGlobalSettings(){ Enabled = true }) }, + { GlobalSettingsType.Rest, JsonSerializer.SerializeToElement(new RestGlobalSettings(){ Enabled = true }) } + }; + + DataSource dataSource = new(DatabaseType.mssql) + { + ConnectionString = GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL) + }; + + Entity restEnabledEntity = new( + Source: JsonSerializer.SerializeToElement("books"), + Rest: JsonSerializer.SerializeToElement(true), + GraphQL: JsonSerializer.SerializeToElement(false), + Permissions: new PermissionSetting[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, + Relationships: null, + Mappings: null); + + RuntimeConfig configuration = InitMinimalRuntimeConfig(globalSettings: settings, dataSource: dataSource, entity: restEnabledEntity, entityName: "Book"); + Entity restDisabledEntity = new( + Source: JsonSerializer.SerializeToElement("publishers"), + Rest: JsonSerializer.SerializeToElement(false), + GraphQL: JsonSerializer.SerializeToElement(true), + Permissions: new PermissionSetting[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, + Relationships: null, + Mappings: null); + + configuration.Entities.Add("Publisher", restDisabledEntity); + const string CUSTOM_CONFIG = "custom-config.json"; + File.WriteAllText( + CUSTOM_CONFIG, + JsonSerializer.Serialize(configuration, RuntimeConfig.SerializerOptions)); + + string[] args = new[] + { + $"--ConfigFileName={CUSTOM_CONFIG}" + }; + + using (TestServer server = new(Program.CreateWebHostBuilder(args))) + using (HttpClient client = server.CreateClient()) + { + // Setup and send request + HttpRequestMessage readOpenApiDocumentRequest = new(HttpMethod.Get, "/openapi"); + HttpResponseMessage response = await client.SendAsync(readOpenApiDocumentRequest); + + // Parse response metadata + string responseBody = await response.Content.ReadAsStringAsync(); + Dictionary responseProperties = JsonSerializer.Deserialize>(responseBody); + + // Validate response metadata + ValidateOpenApiDocTopLevelPropertiesExist(responseProperties); + JsonElement pathsElement = responseProperties[OpenApiDocumentorConstants.TOPLEVELPROPERTY_PATHS]; + + // Validate that paths were created for the entity with REST enabled. + Assert.IsTrue(pathsElement.TryGetProperty("/Book", out _)); + Assert.IsTrue(pathsElement.TryGetProperty("/Book/id/{id}", out _)); + + // Validate that paths were not created for the entity with REST disabled. + Assert.IsFalse(pathsElement.TryGetProperty("/Publisher", out _)); + Assert.IsFalse(pathsElement.TryGetProperty("/Publisher/id/{id}", out _)); + + JsonElement componentsElement = responseProperties[OpenApiDocumentorConstants.TOPLEVELPROPERTY_COMPONENTS]; + Assert.IsTrue(componentsElement.TryGetProperty(OpenApiDocumentorConstants.PROPERTY_SCHEMAS, out JsonElement componentSchemasElement)); + // Validate that components were created for the entity with REST enabled. + Assert.IsTrue(componentSchemasElement.TryGetProperty("Book_NoPK", out _)); + Assert.IsTrue(componentSchemasElement.TryGetProperty("Book", out _)); + + // Validate that components were not created for the entity with REST disabled. + Assert.IsFalse(componentSchemasElement.TryGetProperty("Publisher_NoPK", out _)); + Assert.IsFalse(componentSchemasElement.TryGetProperty("Publisher", out _)); + } + } + + /// + /// Validates that all the OpenAPI description document's top level properties exist. + /// A failure here indicates that there was an undetected failure creating the OpenAPI document. + /// + /// Represent a deserialized JSON result from retrieving the OpenAPI document + private static void ValidateOpenApiDocTopLevelPropertiesExist(Dictionary responseProperties) + { + Assert.IsTrue(responseProperties.ContainsKey(OpenApiDocumentorConstants.TOPLEVELPROPERTY_OPENAPI)); + Assert.IsTrue(responseProperties.ContainsKey(OpenApiDocumentorConstants.TOPLEVELPROPERTY_INFO)); + Assert.IsTrue(responseProperties.ContainsKey(OpenApiDocumentorConstants.TOPLEVELPROPERTY_SERVERS)); + Assert.IsTrue(responseProperties.ContainsKey(OpenApiDocumentorConstants.TOPLEVELPROPERTY_PATHS)); + Assert.IsTrue(responseProperties.ContainsKey(OpenApiDocumentorConstants.TOPLEVELPROPERTY_COMPONENTS)); + } + /// /// Validates that schema introspection requests fail when allow-introspection is false in the runtime configuration. /// @@ -1591,12 +1835,12 @@ public static RuntimeConfig InitMinimalRuntimeConfig( if (entity is null) { entity = new( - Source: JsonSerializer.SerializeToElement("books"), - Rest: null, - GraphQL: JsonSerializer.SerializeToElement(new GraphQLEntitySettings(Type: new SingularPlural(Singular: "book", Plural: "books"))), - Permissions: new PermissionSetting[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, - Relationships: null, - Mappings: null + Source: JsonSerializer.SerializeToElement("books"), + Rest: null, + GraphQL: JsonSerializer.SerializeToElement(new GraphQLEntitySettings(Type: new SingularPlural(Singular: "book", Plural: "books"))), + Permissions: new PermissionSetting[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, + Relationships: null, + Mappings: null ); } diff --git a/src/Service.Tests/OpenApiDocumentor/OpenApiDocumentorConstants.cs b/src/Service.Tests/OpenApiDocumentor/OpenApiDocumentorConstants.cs new file mode 100644 index 0000000000..70dcbe9627 --- /dev/null +++ b/src/Service.Tests/OpenApiDocumentor/OpenApiDocumentorConstants.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.DataApiBuilder.Service.Tests +{ + public class OpenApiDocumentorConstants + { + public const string TOPLEVELPROPERTY_OPENAPI = "openapi"; + public const string TOPLEVELPROPERTY_INFO = "info"; + public const string TOPLEVELPROPERTY_SERVERS = "servers"; + public const string TOPLEVELPROPERTY_PATHS = "paths"; + public const string TOPLEVELPROPERTY_COMPONENTS = "components"; + public const string PROPERTY_SCHEMAS = "schemas"; + } +} diff --git a/src/Service/Controllers/OpenApiController.cs b/src/Service/Controllers/OpenApiController.cs index f19d3f9ad7..659a925a65 100644 --- a/src/Service/Controllers/OpenApiController.cs +++ b/src/Service/Controllers/OpenApiController.cs @@ -3,7 +3,7 @@ using System.Net.Mime; using Azure.DataApiBuilder.Service.Exceptions; -using Azure.DataApiBuilder.Service.Services; +using Azure.DataApiBuilder.Service.Services.OpenAPI; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; diff --git a/src/Service/Services/IOpenApiDocumentor.cs b/src/Service/Services/IOpenApiDocumentor.cs deleted file mode 100644 index 8f670d11fd..0000000000 --- a/src/Service/Services/IOpenApiDocumentor.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Diagnostics.CodeAnalysis; - -namespace Azure.DataApiBuilder.Service.Services -{ - public interface IOpenApiDocumentor - { - public bool TryGetDocument([NotNullWhen(true)] out string? document); - public void CreateDocument(); - } -} diff --git a/src/Service/Services/OpenAPI/IOpenApiDocumentor.cs b/src/Service/Services/OpenAPI/IOpenApiDocumentor.cs new file mode 100644 index 0000000000..84213884af --- /dev/null +++ b/src/Service/Services/OpenAPI/IOpenApiDocumentor.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; + +namespace Azure.DataApiBuilder.Service.Services.OpenAPI +{ + /// + /// Service which generates and provides an OpenAPI description document + /// describing the DAB engine's REST endpoint paths. + /// + public interface IOpenApiDocumentor + { + /// + /// Attempts to return an OpenAPI description document, if generated. + /// + /// String representation of JSON OpenAPI description document. + /// True (plus string representation of document), when document exists. False, otherwise. + public bool TryGetDocument([NotNullWhen(true)] out string? document); + + /// + /// Creates an OpenAPI description document using OpenAPI.NET. + /// Document compliant with patches of OpenAPI V3.0 spec 3.0.0 and 3.0.1, + /// aligned with specification support provided by Microsoft.OpenApi. + /// + /// Raised when document is already generated + /// or a failure occurs during generation. + /// + public void CreateDocument(); + } +} diff --git a/src/Service/Services/OpenAPI/JsonDataType.cs b/src/Service/Services/OpenAPI/JsonDataType.cs new file mode 100644 index 0000000000..f06e091a48 --- /dev/null +++ b/src/Service/Services/OpenAPI/JsonDataType.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.DataApiBuilder.Service.Services.OpenAPI +{ + /// + /// Specifies the data type of a JSON value. + /// Distinguished from System.Text.Json enum JsonValueKind because there are no separate + /// values for JsonValueKind.True or JsonValueKind.False, only a single value JsonDataType.Boolean. + /// This distinction is necessary to facilitate OpenAPI schema creation which requires generic + /// JSON types to be defined for parameters. Because no values are present, JsonValueKind.True/False + /// can't be used. + /// + public enum JsonDataType + { + Undefined = 0, + // + // Summary: + // A JSON object. + Object = 1, + // + // Summary: + // A JSON array. + Array = 2, + // + // Summary: + // A JSON string. + String = 3, + // + // Summary: + // A JSON number. + Number = 4, + // + // Summary: + // A JSON Boolean + Boolean = 5, + // + // Summary: + // The JSON value null. + Null = 6 + } +} diff --git a/src/Service/Services/OpenApiDocumentor.cs b/src/Service/Services/OpenAPI/OpenApiDocumentor.cs similarity index 90% rename from src/Service/Services/OpenApiDocumentor.cs rename to src/Service/Services/OpenAPI/OpenApiDocumentor.cs index 18826217c8..acd44a5d8e 100644 --- a/src/Service/Services/OpenApiDocumentor.cs +++ b/src/Service/Services/OpenAPI/OpenApiDocumentor.cs @@ -10,7 +10,6 @@ using System.Net; using System.Net.Mime; using System.Text; -using System.Text.Json; using Azure.DataApiBuilder.Config; using Azure.DataApiBuilder.Service.Configurations; using Azure.DataApiBuilder.Service.Exceptions; @@ -18,7 +17,7 @@ using Microsoft.OpenApi.Writers; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace Azure.DataApiBuilder.Service.Services +namespace Azure.DataApiBuilder.Service.Services.OpenAPI { /// /// Service which generates and provides an OpenAPI description document @@ -33,8 +32,6 @@ public class OpenApiDocumentor : IOpenApiDocumentor private const string DOCUMENTOR_VERSION = "PREVIEW"; private const string DOCUMENTOR_UI_TITLE = "Data API builder - REST Endpoint"; - private const string DOCUMENT_ALREADY_GENERATED_ERROR = "OpenAPI description document already generated."; - private const string DOCUMENT_CREATION_UNSUPPORTED_ERROR = "OpenAPI description document can't be created when the REST endpoint is disabled globally."; private const string GETALL_DESCRIPTION = "Returns entities."; private const string GETONE_DESCRIPTION = "Returns an entity."; private const string POST_DESCRIPTION = "Create entity."; @@ -44,6 +41,10 @@ public class OpenApiDocumentor : IOpenApiDocumentor private const string RESPONSE_VALUE_PROPERTY = "value"; private const string RESPONSE_ARRAY_PROPERTY = "array"; + // Error messages + public const string DOCUMENT_ALREADY_GENERATED_ERROR = "OpenAPI description document already generated."; + public const string DOCUMENT_CREATION_UNSUPPORTED_ERROR = "OpenAPI description document can't be created when the REST endpoint is disabled globally."; + /// /// Constructor denotes required services whose metadata is used to generate the OpenAPI description document. /// @@ -82,11 +83,12 @@ public bool TryGetDocument([NotNullWhen(true)] out string? document) /// /// Creates an OpenAPI description document using OpenAPI.NET. - /// Document compliant with all patches of OpenAPI V3.0 spec (e.g. 3.0.0, 3.0.1) + /// Document compliant with patches of OpenAPI V3.0 spec 3.0.0 and 3.0.1, + /// aligned with specification support provided by Microsoft.OpenApi. /// /// Raised when document is already generated /// or a failure occurs during generation. - /// + /// public void CreateDocument() { if (_openApiDocument is not null) @@ -140,13 +142,19 @@ public void CreateDocument() } /// - /// Iteratrs through the runtime configuration's entities and generates the path object + /// Iterates through the runtime configuration's entities and generates the path object /// representing the DAB engine's supported HTTP verbs and relevant route restrictions: - /// Routes including primary key: + /// Paths including primary key: /// - GET (by ID), PUT, PATCH, DELETE - /// Routes excluding primary key: + /// Paths excluding primary key: /// - GET (all), POST /// + /// + /// A path with primary key where the parameter in curly braces {} represents the preceding primary key's value. + /// "/EntityName/primaryKeyName/{primaryKeyValue}" + /// A path with no primary key nor parameter representing the primary key value: + /// "/EntityName" + /// /// All possible paths in the DAB engine's REST API endpoint. private OpenApiPaths BuildPaths() { @@ -183,9 +191,11 @@ private OpenApiPaths BuildPaths() /// Includes Path with Operations(+responses) and Parameters /// Parameters are the placeholders for pk values in curly braces { } in the URL route /// localhost:5000/api/Entity/pk1/{pk1}/pk2/{pk2} - /// /{entityName/RestPathName} + {/pk/{pkValue}} * N + /// more generically: + /// /entityName OR /RestPathName followed by (/pk/{pkValue}) * Number of primary keys. /// - /// + /// Tuple of the path value: e.g. "/Entity/pk1/{pk1}" + /// and path metadata. private Tuple BuildPath(string entityName, bool includePrimaryKeyPathComponent) { SourceDefinition sourceDefinition = _metadataProvider.GetSourceDefinition(entityName); @@ -342,6 +352,8 @@ private static OpenApiRequestBody CreateOpenApiRequestBodyPayload(string schemaR /// /// Parameters are the placeholders for pk values in curly braces { } in the URL route /// https://localhost:5000/api/Entity/pk1/{pk1}/pk2/{pk2} + /// This function creates the string value "/Entity/pk1/{pk1}/pk2/{pk2}" + /// and creates associated parameters. /// /// Name of the entity. /// Primary Key path component. Empty string if no primary keys exist on database object source definition. @@ -351,6 +363,7 @@ private Tuple> CreatePrimaryKeyPathComponentAndPa List parameters = new(); StringBuilder pkComponents = new(); + // Each primary key must be represented in the path component. foreach (string column in sourceDefinition.PrimaryKey) { string columnNameForComponent = column; @@ -364,7 +377,7 @@ private Tuple> CreatePrimaryKeyPathComponentAndPa { OpenApiSchema parameterSchema = new() { - Type = (columnDef is not null) ? SystemTypeToJsonValueKind(columnDef.SystemType) : string.Empty + Type = columnDef is not null ? SystemTypeToJsonDataType(columnDef.SystemType) : string.Empty }; OpenApiParameter openApiParameter = new() @@ -454,7 +467,7 @@ private string GetEntityRestPath(string entityName) } } - Assert.IsFalse(string.Equals('/', entityRestPath)); + Assert.IsFalse(Equals('/', entityRestPath)); return entityRestPath; } @@ -554,6 +567,19 @@ private Dictionary CreateComponentSchemas() foreach (string entityName in _metadataProvider.EntityToDatabaseObject.Keys.ToList()) { + // Entities which disable their REST endpoint must not be included in + // the OpenAPI description document. + if (_runtimeConfig.Entities.TryGetValue(entityName, out Entity? entity) && entity is not null) + { + if (entity.GetRestEnabledOrPathSettings() is bool restEnabled) + { + if (!restEnabled) + { + continue; + } + } + } + SourceDefinition sourceDefinition = _metadataProvider.GetSourceDefinition(entityName); List columns = /*sourceDefinition is null ? new List() : */sourceDefinition.Columns.Keys.ToList(); @@ -604,7 +630,7 @@ private OpenApiSchema CreateComponentSchema(string entityName, List fiel string formatMetadata = string.Empty; if (dbObject.SourceDefinition.Columns.TryGetValue(backingColumnValue, out ColumnDefinition? columnDef) && columnDef is not null) { - typeMetadata = SystemTypeToJsonValueKind(columnDef.SystemType).ToString().ToLower(); + typeMetadata = SystemTypeToJsonDataType(columnDef.SystemType).ToString().ToLower(); } properties.Add(field, new OpenApiSchema() @@ -647,32 +673,32 @@ private static OpenApiResponses CreateDefaultOpenApiResponses() } /// - /// Converts the CLR type to System.Text.Json's JsonValueKind + /// Converts the CLR type to JsonDataType /// to meet the data type requirement set by the OpenAPI specification. /// The value returned is formatted for the OpenAPI spec "type" property. /// /// CLR type /// /// Formatted JSON type name in lower case: e.g. number, string, boolean, etc. - private static string SystemTypeToJsonValueKind(Type type) + private static string SystemTypeToJsonDataType(Type type) { - JsonValueKind openApiTypeName = type.Name switch + JsonDataType openApiTypeName = type.Name switch { - "String" => JsonValueKind.String, - "Guid" => JsonValueKind.String, - "Byte" => JsonValueKind.String, - "Int16" => JsonValueKind.Number, - "Int32" => JsonValueKind.Number, - "Int64" => JsonValueKind.Number, - "Single" => JsonValueKind.Number, - "Double" => JsonValueKind.Number, - "Decimal" => JsonValueKind.Number, - "Float" => JsonValueKind.Number, - "Boolean" => JsonValueKind.True, - "DateTime" => JsonValueKind.String, - "DateTimeOffset" => JsonValueKind.String, - "Byte[]" => JsonValueKind.String, - _ => JsonValueKind.Undefined + "String" => JsonDataType.String, + "Guid" => JsonDataType.String, + "Byte" => JsonDataType.String, + "Int16" => JsonDataType.Number, + "Int32" => JsonDataType.Number, + "Int64" => JsonDataType.Number, + "Single" => JsonDataType.Number, + "Double" => JsonDataType.Number, + "Decimal" => JsonDataType.Number, + "Float" => JsonDataType.Number, + "Boolean" => JsonDataType.Boolean, + "DateTime" => JsonDataType.String, + "DateTimeOffset" => JsonDataType.String, + "Byte[]" => JsonDataType.String, + _ => JsonDataType.Undefined }; string formattedOpenApiTypeName = openApiTypeName.ToString().ToLower(); diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index c8419f963b..7f34aa1064 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -19,6 +19,7 @@ using Azure.DataApiBuilder.Service.Resolvers; using Azure.DataApiBuilder.Service.Services; using Azure.DataApiBuilder.Service.Services.MetadataProviders; +using Azure.DataApiBuilder.Service.Services.OpenAPI; using HotChocolate.AspNetCore; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; @@ -375,10 +376,9 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, RuntimeC // in developer mode in alignment with the restriction placed on ChilliCream's BananaCakePop IDE. if (runtimeConfigProvider.IsDeveloperMode() || env.IsDevelopment()) { - //app.UseStaticFiles(); app.UseSwaggerUI(c => { - c.SwaggerEndpoint("/openapi", "DataAPIbuilder-OpenAPI-Alpha"); + c.SwaggerEndpoint("/openapi", "DataApibuilder-OpenAPI-PREVIEW"); }); } From 9941fb7236ab6a92927395d59ffd99018ea77e10 Mon Sep 17 00:00:00 2001 From: Sean Leonard Date: Wed, 3 May 2023 01:07:05 -0700 Subject: [PATCH 15/30] Moved placement of swaggerUI setup, to fix HTTP400 errors --- src/Service/Startup.cs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index 7f34aa1064..d3e48010bc 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -357,6 +357,16 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, RuntimeC app.UseHttpsRedirection(); } + // SwaggerUI visualization of the OpenAPI description document is only available + // in developer mode in alignment with the restriction placed on ChilliCream's BananaCakePop IDE. + if (runtimeConfigProvider.IsDeveloperMode() || env.IsDevelopment()) + { + app.UseSwaggerUI(c => + { + c.SwaggerEndpoint("/openapi", "DataApibuilder-OpenAPI-PREVIEW"); + }); + } + // URL Rewrite middleware MUST be called prior to UseRouting(). // https://andrewlock.net/understanding-pathbase-in-aspnetcore/#placing-usepathbase-in-the-correct-location app.UseCorrelationIdMiddleware(); @@ -372,15 +382,6 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, RuntimeC ConfigureCors(CORSPolicyBuilder, corsConfig); }); } - // SwaggerUI visualization of the OpenAPI description document is only available - // in developer mode in alignment with the restriction placed on ChilliCream's BananaCakePop IDE. - if (runtimeConfigProvider.IsDeveloperMode() || env.IsDevelopment()) - { - app.UseSwaggerUI(c => - { - c.SwaggerEndpoint("/openapi", "DataApibuilder-OpenAPI-PREVIEW"); - }); - } app.Use(async (context, next) => { From 058c53dff96f02df65e956b4a2a82e03ec42dfc4 Mon Sep 17 00:00:00 2001 From: Sean Leonard Date: Wed, 3 May 2023 15:18:16 -0700 Subject: [PATCH 16/30] Remove ability to POST on openapicontroller since creation occurs on startup or during late config instantiation. Remove testing of POST. Move SwaggerUI middleware setup to occur after correlationid and pathrewritemiddleware to support later route customization. Also added tests to validate late config behavior with swaggerui endpoint and openapi endpoint/presence of document. --- .../Configuration/ConfigurationTests.cs | 151 ++++++++++-------- src/Service/Controllers/OpenApiController.cs | 42 ----- src/Service/Startup.cs | 16 +- 3 files changed, 95 insertions(+), 114 deletions(-) diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index 2107fd57be..14125e3dfc 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -25,6 +25,7 @@ using Azure.DataApiBuilder.Service.Tests.Authorization; using Azure.DataApiBuilder.Service.Tests.SqlTests; using HotChocolate; +using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.TestHost; using Microsoft.Data.SqlClient; using Microsoft.Extensions.DependencyInjection; @@ -49,6 +50,9 @@ public class ConfigurationTests private const string POST_STARTUP_CONFIG_ENTITY_SOURCE = "books"; private const string POST_STARTUP_CONFIG_ROLE = "PostStartupConfigRole"; private const string COSMOS_DATABASE_NAME = "config_db"; + private const string CUSTOM_CONFIG_FILENAME = "custom-config.json"; + private const string OPENAPI_SWAGGER_ENDPOINT = "swagger"; + private const string OPENAPI_DOCUMENT_ENDPOINT = "openapi"; private const int RETRY_COUNT = 5; private const int RETRY_WAIT_SECONDS = 1; @@ -393,9 +397,18 @@ public async Task TestSqlSettingPostStartupConfigurations() ConfigurationPostParameters config = GetPostStartupConfigParams(MSSQL_ENVIRONMENT, configuration); - HttpResponseMessage preConfigHydradtionResult = + HttpResponseMessage preConfigHydrationResult = await httpClient.GetAsync($"/{POST_STARTUP_CONFIG_ENTITY}"); - Assert.AreEqual(HttpStatusCode.ServiceUnavailable, preConfigHydradtionResult.StatusCode); + Assert.AreEqual(HttpStatusCode.ServiceUnavailable, preConfigHydrationResult.StatusCode); + + HttpResponseMessage preConfigOpenApiDocumentExistence = + await httpClient.GetAsync($"/{OPENAPI_DOCUMENT_ENDPOINT}"); + Assert.AreEqual(HttpStatusCode.ServiceUnavailable, preConfigOpenApiDocumentExistence.StatusCode); + + // SwaggerUI (OpenAPI user interface) is not made available in production/hosting mode. + HttpResponseMessage preConfigOpenApiSwaggerEndpointAvailability = + await httpClient.GetAsync($"/{OPENAPI_SWAGGER_ENDPOINT}"); + Assert.AreEqual(HttpStatusCode.ServiceUnavailable, preConfigOpenApiSwaggerEndpointAvailability.StatusCode); HttpStatusCode responseCode = await HydratePostStartupConfiguration(httpClient, config); @@ -416,6 +429,19 @@ public async Task TestSqlSettingPostStartupConfigurations() message.Headers.Add(AuthorizationResolver.CLIENT_ROLE_HEADER, POST_STARTUP_CONFIG_ROLE); HttpResponseMessage authorizedResponse = await httpClient.SendAsync(message); Assert.AreEqual(expected: HttpStatusCode.OK, actual: authorizedResponse.StatusCode); + + // OpenAPI document is created during config hydration and + // is made available after config hydration completes. + HttpResponseMessage postConfigOpenApiDocumentExistence = + await httpClient.GetAsync($"/{OPENAPI_DOCUMENT_ENDPOINT}"); + Assert.AreEqual(HttpStatusCode.OK, postConfigOpenApiDocumentExistence.StatusCode); + + // SwaggerUI (OpenAPI user interface) is not made available in production/hosting mode. + // HTTP 400 - BadRequest because when SwaggerUI is disabled, the endpoint is not mapped + // and the request is processed and failed by the RestService. + HttpResponseMessage postConfigOpenApiSwaggerEndpointAvailability = + await httpClient.GetAsync($"/{OPENAPI_SWAGGER_ENDPOINT}"); + Assert.AreEqual(HttpStatusCode.BadRequest, postConfigOpenApiSwaggerEndpointAvailability.StatusCode); } [TestMethod("Validates that local cosmosdb_nosql settings can be loaded and the correct classes are in the service provider."), TestCategory(TestCategory.COSMOSDBNOSQL)] @@ -1390,7 +1416,6 @@ public void TestInvalidDatabaseColumnNameHandling( } } - /// /// Test different graphql endpoints in different host modes /// when accessed interactively via browser. @@ -1459,66 +1484,41 @@ public async Task OpenApi_InteractiveSwaggerUI( /// - GET to /openapi returns the created OpenAPI document and succeeds with 200 OK. /// [DataTestMethod] - [DataRow(true, false, DisplayName = "Global REST endpoint enabled - conflict creating OpenAPI doc and successful retrieval")] - [DataRow(false, true, DisplayName = "Global REST endpoint disabled - failure to create and retrieve OpenAPI doc")] + [DataRow(true, false, DisplayName = "Global REST endpoint enabled - successful OpenAPI doc retrieval")] + [DataRow(false, true, DisplayName = "Global REST endpoint disabled - OpenAPI doc does not exist - HTTP404 NotFound.")] [TestCategory(TestCategory.MSSQL)] public async Task OpenApi_GlobalEntityRestPath(bool globalRestEnabled, bool expectsError) { - Dictionary settings = new() - { - { GlobalSettingsType.GraphQL, JsonSerializer.SerializeToElement(new GraphQLGlobalSettings(){ Enabled = true }) }, - { GlobalSettingsType.Rest, JsonSerializer.SerializeToElement(new RestGlobalSettings(){ Enabled = globalRestEnabled }) } - }; + // At least one entity is required in the runtime config for the engine to start. + // Even though this entity is not under test, it must be supplied to the config + // file creation function. + Entity requiredEntity = new( + Source: JsonSerializer.SerializeToElement("books"), + Rest: JsonSerializer.SerializeToElement(false), + GraphQL: JsonSerializer.SerializeToElement(true), + Permissions: new PermissionSetting[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, + Relationships: null, + Mappings: null); - DataSource dataSource = new(DatabaseType.mssql) + Dictionary entityMap = new() { - ConnectionString = GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL) + { "Book", requiredEntity } }; - RuntimeConfig configuration = InitMinimalRuntimeConfig(globalSettings: settings, dataSource: dataSource); - const string CUSTOM_CONFIG = "custom-config.json"; - File.WriteAllText( - CUSTOM_CONFIG, - JsonSerializer.Serialize(configuration, RuntimeConfig.SerializerOptions)); + CreateCustomConfigFile(globalRestEnabled: globalRestEnabled, entityMap); string[] args = new[] { - $"--ConfigFileName={CUSTOM_CONFIG}" + $"--ConfigFileName={CUSTOM_CONFIG_FILENAME}" }; using (TestServer server = new(Program.CreateWebHostBuilder(args))) using (HttpClient client = server.CreateClient()) { - // Setup and send request - HttpRequestMessage createOpenApiDocumentRequest = new(HttpMethod.Post, "/openapi"); - HttpResponseMessage response = await client.SendAsync(createOpenApiDocumentRequest); - - // Parse response metadata - string responseBody = await response.Content.ReadAsStringAsync(); - - if (expectsError) - { - Dictionary actualAsDict = JsonSerializer.Deserialize>(responseBody); - JsonElement actualErrorObj = actualAsDict["error"]; - string responseBodyStatusCode = actualErrorObj.GetProperty("status").ToString(); - string responseBodySubStatusCode = actualErrorObj.GetProperty("code").ToString(); - string responseBodyMessage = actualErrorObj.GetProperty("message").ToString(); - - // Validate response - Assert.AreEqual(HttpStatusCode.MethodNotAllowed, response.StatusCode); - Assert.AreEqual(OpenApiDocumentor.DOCUMENT_CREATION_UNSUPPORTED_ERROR, responseBodyMessage); - Assert.AreEqual(((int)HttpStatusCode.MethodNotAllowed).ToString(), responseBodyStatusCode); - Assert.AreEqual(DataApiBuilderException.SubStatusCodes.GlobalRestEndpointDisabled.ToString(), responseBodySubStatusCode); - } - else - { - Assert.AreEqual(HttpStatusCode.Conflict, response.StatusCode); - } - // Setup and send request HttpRequestMessage readOpenApiDocumentRequest = new(HttpMethod.Get, "/openapi"); - response = await client.SendAsync(readOpenApiDocumentRequest); - responseBody = await response.Content.ReadAsStringAsync(); + HttpResponseMessage response = await client.SendAsync(readOpenApiDocumentRequest); + string responseBody = await response.Content.ReadAsStringAsync(); Dictionary responseProperties = JsonSerializer.Deserialize>(responseBody); // Validate response @@ -1544,17 +1544,7 @@ public async Task OpenApi_GlobalEntityRestPath(bool globalRestEnabled, bool expe [TestMethod] public async Task OpenApi_EntityLevelRestEndpoint() { - Dictionary settings = new() - { - { GlobalSettingsType.GraphQL, JsonSerializer.SerializeToElement(new GraphQLGlobalSettings(){ Enabled = true }) }, - { GlobalSettingsType.Rest, JsonSerializer.SerializeToElement(new RestGlobalSettings(){ Enabled = true }) } - }; - - DataSource dataSource = new(DatabaseType.mssql) - { - ConnectionString = GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL) - }; - + // Create the entities under test. Entity restEnabledEntity = new( Source: JsonSerializer.SerializeToElement("books"), Rest: JsonSerializer.SerializeToElement(true), @@ -1563,7 +1553,6 @@ public async Task OpenApi_EntityLevelRestEndpoint() Relationships: null, Mappings: null); - RuntimeConfig configuration = InitMinimalRuntimeConfig(globalSettings: settings, dataSource: dataSource, entity: restEnabledEntity, entityName: "Book"); Entity restDisabledEntity = new( Source: JsonSerializer.SerializeToElement("publishers"), Rest: JsonSerializer.SerializeToElement(false), @@ -1572,15 +1561,17 @@ public async Task OpenApi_EntityLevelRestEndpoint() Relationships: null, Mappings: null); - configuration.Entities.Add("Publisher", restDisabledEntity); - const string CUSTOM_CONFIG = "custom-config.json"; - File.WriteAllText( - CUSTOM_CONFIG, - JsonSerializer.Serialize(configuration, RuntimeConfig.SerializerOptions)); + Dictionary entityMap = new() + { + { "Book", restEnabledEntity }, + { "Publisher", restDisabledEntity } + }; + + CreateCustomConfigFile(globalRestEnabled: true, entityMap); string[] args = new[] { - $"--ConfigFileName={CUSTOM_CONFIG}" + $"--ConfigFileName={CUSTOM_CONFIG_FILENAME}" }; using (TestServer server = new(Program.CreateWebHostBuilder(args))) @@ -1618,6 +1609,38 @@ public async Task OpenApi_EntityLevelRestEndpoint() } } + /// + /// Helper function to write custom configuration file. with minimal REST/GraphQL global settings + /// using the supplied entities. + /// + /// flag to enable or disabled REST globally. + /// Collection of entityName -> Entity object. + private static void CreateCustomConfigFile(bool globalRestEnabled, Dictionary entityMap) + { + Dictionary globalSettings = new() + { + { GlobalSettingsType.GraphQL, JsonSerializer.SerializeToElement(new GraphQLGlobalSettings(){ Enabled = true }) }, + { GlobalSettingsType.Rest, JsonSerializer.SerializeToElement(new RestGlobalSettings(){ Enabled = globalRestEnabled }) } + }; + + DataSource dataSource = new(DatabaseType.mssql) + { + ConnectionString = GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL) + }; + + RuntimeConfig runtimeConfig = new( + Schema: string.Empty, + DataSource: dataSource, + RuntimeSettings: globalSettings, + Entities: entityMap); + + runtimeConfig.DetermineGlobalSettings(); + + File.WriteAllText( + path: CUSTOM_CONFIG_FILENAME, + contents: JsonSerializer.Serialize(runtimeConfig, RuntimeConfig.SerializerOptions)); + } + /// /// Validates that all the OpenAPI description document's top level properties exist. /// A failure here indicates that there was an undetected failure creating the OpenAPI document. diff --git a/src/Service/Controllers/OpenApiController.cs b/src/Service/Controllers/OpenApiController.cs index 659a925a65..7e79d4851c 100644 --- a/src/Service/Controllers/OpenApiController.cs +++ b/src/Service/Controllers/OpenApiController.cs @@ -47,47 +47,5 @@ public IActionResult Get() return NotFound(); } - - /// - /// Trigger the creation of the OpenAPI description document if it wasn't already created - /// using this method or created during engine startup. - /// - /// - /// HTTP 201 - OpenAPI description document if creation was triggered - /// HTTP 405 - Document creation method not allowed, global REST endpoint disabled in runtime config. - /// HTTP 409 - Document already created - /// HTTP 500 - Document creation failed. - [HttpPost] - [ProducesResponseType(StatusCodes.Status201Created)] - [ProducesResponseType(StatusCodes.Status405MethodNotAllowed)] - [ProducesResponseType(StatusCodes.Status409Conflict)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public IActionResult Post() - { - try - { - _apiDocumentor.CreateDocument(); - - if (_apiDocumentor.TryGetDocument(out string? document)) - { - return new CreatedResult(location: "/openapi", value: document); - } - - return NotFound(); - } - catch (DataApiBuilderException dabException) - { - Response.StatusCode = (int)dabException.StatusCode; - return new JsonResult(new - { - error = new - { - code = dabException.SubStatusCode.ToString(), - message = dabException.Message, - status = (int)dabException.StatusCode - } - }); - } - } } } diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index d3e48010bc..475304c5f3 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -357,6 +357,11 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, RuntimeC app.UseHttpsRedirection(); } + // URL Rewrite middleware MUST be called prior to UseRouting(). + // https://andrewlock.net/understanding-pathbase-in-aspnetcore/#placing-usepathbase-in-the-correct-location + app.UseCorrelationIdMiddleware(); + app.UsePathRewriteMiddleware(); + // SwaggerUI visualization of the OpenAPI description document is only available // in developer mode in alignment with the restriction placed on ChilliCream's BananaCakePop IDE. if (runtimeConfigProvider.IsDeveloperMode() || env.IsDevelopment()) @@ -367,10 +372,6 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, RuntimeC }); } - // URL Rewrite middleware MUST be called prior to UseRouting(). - // https://andrewlock.net/understanding-pathbase-in-aspnetcore/#placing-usepathbase-in-the-correct-location - app.UseCorrelationIdMiddleware(); - app.UsePathRewriteMiddleware(); app.UseRouting(); // Adding CORS Middleware @@ -634,13 +635,12 @@ private async Task PerformOnConfigChangeAsync(IApplicationBuilder app) runtimeConfigValidator.ValidateStoredProceduresInConfig(runtimeConfig, sqlMetadataProvider!); // Attempt to create OpenAPI document. - // Any failures should result in a logged error, but must not halt runtime initialization + // Errors must not crash nor halt the intialization of the engine // because OpenAPI document creation is not required for the engine to operate. + // Errors will be logged. try { - IOpenApiDocumentor openApiDocumentor = - app.ApplicationServices.GetRequiredService(); - + IOpenApiDocumentor openApiDocumentor = app.ApplicationServices.GetRequiredService(); openApiDocumentor.CreateDocument(); } catch (DataApiBuilderException dabException) From 12d25fb0ad287380bcebc4c64e40bfbf16fea397 Mon Sep 17 00:00:00 2001 From: Sean Leonard Date: Wed, 3 May 2023 15:19:45 -0700 Subject: [PATCH 17/30] remove unnecessary usings --- src/Service.Tests/Configuration/ConfigurationTests.cs | 2 -- src/Service/Controllers/OpenApiController.cs | 1 - 2 files changed, 3 deletions(-) diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index 14125e3dfc..8a991070a9 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -21,11 +21,9 @@ using Azure.DataApiBuilder.Service.Resolvers; using Azure.DataApiBuilder.Service.Services; using Azure.DataApiBuilder.Service.Services.MetadataProviders; -using Azure.DataApiBuilder.Service.Services.OpenAPI; using Azure.DataApiBuilder.Service.Tests.Authorization; using Azure.DataApiBuilder.Service.Tests.SqlTests; using HotChocolate; -using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.TestHost; using Microsoft.Data.SqlClient; using Microsoft.Extensions.DependencyInjection; diff --git a/src/Service/Controllers/OpenApiController.cs b/src/Service/Controllers/OpenApiController.cs index 7e79d4851c..b5333f83a0 100644 --- a/src/Service/Controllers/OpenApiController.cs +++ b/src/Service/Controllers/OpenApiController.cs @@ -2,7 +2,6 @@ // Licensed under the MIT License. using System.Net.Mime; -using Azure.DataApiBuilder.Service.Exceptions; using Azure.DataApiBuilder.Service.Services.OpenAPI; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; From e79f905c283785ad6d813e4ef9c1231985d5b00e Mon Sep 17 00:00:00 2001 From: Sean Leonard Date: Wed, 3 May 2023 15:23:54 -0700 Subject: [PATCH 18/30] updated comments to correctly reflect operations and expected results. --- src/Service.Tests/Configuration/ConfigurationTests.cs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index 8a991070a9..89c2ba622f 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -1474,12 +1474,9 @@ public async Task OpenApi_InteractiveSwaggerUI( /// Validates the OpenAPI documentor behavior when enabling and disabling the global REST endpoint /// for the DAB engine. /// Global REST enabled: - /// - POST to /openapi triggers OpenAPI document creation but should fail with 405 Method Not Allowed. - /// - GET to /openapi returns the created OpenAPI document, but should fail with 404 Not Found. - /// Global REST disabled: - /// - POST to /openapi triggers OpenAPI document creation fails with 409 Conflict because the - /// document was already created during engine startup. /// - GET to /openapi returns the created OpenAPI document and succeeds with 200 OK. + /// Global REST disabled: + /// - GET to /openapi fails with 404 Not Found. /// [DataTestMethod] [DataRow(true, false, DisplayName = "Global REST endpoint enabled - successful OpenAPI doc retrieval")] @@ -1513,7 +1510,7 @@ public async Task OpenApi_GlobalEntityRestPath(bool globalRestEnabled, bool expe using (TestServer server = new(Program.CreateWebHostBuilder(args))) using (HttpClient client = server.CreateClient()) { - // Setup and send request + // Setup and send GET request HttpRequestMessage readOpenApiDocumentRequest = new(HttpMethod.Get, "/openapi"); HttpResponseMessage response = await client.SendAsync(readOpenApiDocumentRequest); string responseBody = await response.Content.ReadAsStringAsync(); @@ -1575,7 +1572,7 @@ public async Task OpenApi_EntityLevelRestEndpoint() using (TestServer server = new(Program.CreateWebHostBuilder(args))) using (HttpClient client = server.CreateClient()) { - // Setup and send request + // Setup and send GET request HttpRequestMessage readOpenApiDocumentRequest = new(HttpMethod.Get, "/openapi"); HttpResponseMessage response = await client.SendAsync(readOpenApiDocumentRequest); From 28b4120eba10383048c23a1e99a8c0153804b8a7 Mon Sep 17 00:00:00 2001 From: Sean Leonard Date: Fri, 5 May 2023 08:08:42 -0700 Subject: [PATCH 19/30] updating function comments in interface, updating documentor usage of to use exposed instead of backing column names --- src/Service/Controllers/OpenApiController.cs | 5 +- .../Services/OpenAPI/IOpenApiDocumentor.cs | 6 +-- src/Service/Services/OpenAPI/JsonDataType.cs | 36 ++++++------- .../Services/OpenAPI/OpenApiDocumentor.cs | 54 +++++++++++++++---- 4 files changed, 66 insertions(+), 35 deletions(-) diff --git a/src/Service/Controllers/OpenApiController.cs b/src/Service/Controllers/OpenApiController.cs index b5333f83a0..d56d31f6be 100644 --- a/src/Service/Controllers/OpenApiController.cs +++ b/src/Service/Controllers/OpenApiController.cs @@ -9,8 +9,7 @@ namespace Azure.DataApiBuilder.Service.Controllers { /// - /// Facilitate access to a created OpenAPI description document or trigger the creation of - /// the OpenAPI description document. + /// Facilitate access to the created OpenAPI description document. /// [Route("[controller]")] [ApiController] @@ -35,7 +34,7 @@ public OpenApiController(IOpenApiDocumentor openApiDocumentor) /// HTTP 404 - OpenAPI description document not available since it hasn't been created /// or failed to be created. [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(type: typeof(string), statusCode: StatusCodes.Status200OK, contentType: MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status404NotFound)] public IActionResult Get() { diff --git a/src/Service/Services/OpenAPI/IOpenApiDocumentor.cs b/src/Service/Services/OpenAPI/IOpenApiDocumentor.cs index 84213884af..7876a507b8 100644 --- a/src/Service/Services/OpenAPI/IOpenApiDocumentor.cs +++ b/src/Service/Services/OpenAPI/IOpenApiDocumentor.cs @@ -6,13 +6,13 @@ namespace Azure.DataApiBuilder.Service.Services.OpenAPI { /// - /// Service which generates and provides an OpenAPI description document - /// describing the DAB engine's REST endpoint paths. + /// Interface for the service which generates and provides the OpenAPI description document + /// describing the DAB engine's entity REST endpoint paths. /// public interface IOpenApiDocumentor { /// - /// Attempts to return an OpenAPI description document, if generated. + /// Attempts to return the OpenAPI description document, if generated. /// /// String representation of JSON OpenAPI description document. /// True (plus string representation of document), when document exists. False, otherwise. diff --git a/src/Service/Services/OpenAPI/JsonDataType.cs b/src/Service/Services/OpenAPI/JsonDataType.cs index f06e091a48..517a1d0c67 100644 --- a/src/Service/Services/OpenAPI/JsonDataType.cs +++ b/src/Service/Services/OpenAPI/JsonDataType.cs @@ -14,29 +14,29 @@ namespace Azure.DataApiBuilder.Service.Services.OpenAPI public enum JsonDataType { Undefined = 0, - // - // Summary: - // A JSON object. + /// + /// A JSON Object + /// Object = 1, - // - // Summary: - // A JSON array. + /// + /// A JSON array + /// Array = 2, - // - // Summary: - // A JSON string. + /// + /// A JSON string + /// String = 3, - // - // Summary: - // A JSON number. + /// + /// A JSON number + /// Number = 4, - // - // Summary: - // A JSON Boolean + /// + /// A JSON Boolean + /// Boolean = 5, - // - // Summary: - // The JSON value null. + /// + /// The JSON value null + /// Null = 6 } } diff --git a/src/Service/Services/OpenAPI/OpenApiDocumentor.cs b/src/Service/Services/OpenAPI/OpenApiDocumentor.cs index acd44a5d8e..cf9b96edfe 100644 --- a/src/Service/Services/OpenAPI/OpenApiDocumentor.cs +++ b/src/Service/Services/OpenAPI/OpenApiDocumentor.cs @@ -20,8 +20,8 @@ namespace Azure.DataApiBuilder.Service.Services.OpenAPI { /// - /// Service which generates and provides an OpenAPI description document - /// describing the DAB engine's REST endpoint paths. + /// Service which generates and provides the OpenAPI description document + /// describing the DAB engine's entity REST endpoint paths. /// public class OpenApiDocumentor : IOpenApiDocumentor { @@ -205,7 +205,7 @@ private Tuple BuildPath(string entityName, bool include // When the source's primary key(s) are autogenerated, the PUT, PATCH, and POST request // bodies must not include the primary key(s). - string schemaReferenceId = SourceContainsAutogeneratedPrimaryKey(sourceDefinition) ? $"{entityName}_NoPK" : $"{entityName}"; + string schemaReferenceId = DoesSourceContainsAutogeneratedPrimaryKey(sourceDefinition) ? $"{entityName}_NoPK" : $"{entityName}"; bool requestBodyRequired = IsRequestBodyRequired(sourceDefinition); @@ -373,7 +373,8 @@ private Tuple> CreatePrimaryKeyPathComponentAndPa columnNameForComponent = mappedColumnAlias; } - if (sourceDefinition.Columns.TryGetValue(columnNameForComponent, out ColumnDefinition? columnDef)) + // The SourceDefinition's Columns dictionary keys represent the original (unmapped) column names. + if (sourceDefinition.Columns.TryGetValue(column, out ColumnDefinition? columnDef)) { OpenApiSchema parameterSchema = new() { @@ -399,11 +400,13 @@ private Tuple> CreatePrimaryKeyPathComponentAndPa /// /// Determines whether the database object has an autogenerated primary key - /// used to distinguish which request and response objects should reference the primary key. + /// used to distinguish which requests can supply a value for the primary key. + /// e.g. a POST request definition may not define request body field that includes + /// a primary key which is autogenerated. /// /// Database object metadata. /// True, when the primary key is autogenerated. Otherwise, false. - private static bool SourceContainsAutogeneratedPrimaryKey(SourceDefinition sourceDefinition) + private static bool DoesSourceContainsAutogeneratedPrimaryKey(SourceDefinition sourceDefinition) { bool sourceObjectHasAutogeneratedPK = false; // Create primary key path component. @@ -414,6 +417,7 @@ private static bool SourceContainsAutogeneratedPrimaryKey(SourceDefinition sourc if (sourceDefinition.Columns.TryGetValue(columnNameForComponent, out ColumnDefinition? columnDef) && columnDef is not null && columnDef.IsAutoGenerated) { sourceObjectHasAutogeneratedPK = true; + break; } } @@ -557,7 +561,7 @@ private static OpenApiMediaType CreateResponseContainer(string responseObjectSch /// Builds the schema objects for all entities present in the runtime configuration. /// Two schemas per entity are created: /// 1) {EntityName} -> Primary keys present in schema, used for request bodies (excluding GET) and all response bodies. - /// 2) {EntityName}_NoPK -> No primary keys present in schema, used for POST requests where PK is autogenerated. + /// 2) {EntityName}_NoPK -> No primary keys present in schema, used for POST requests where PK is autogenerated and GET (all). /// Schema objects can be referenced elsewhere in the OpenAPI document with the intent to reduce document verbosity. /// /// Collection of schemas for entities defined in the runtime configuration. @@ -581,23 +585,27 @@ private Dictionary CreateComponentSchemas() } SourceDefinition sourceDefinition = _metadataProvider.GetSourceDefinition(entityName); - List columns = /*sourceDefinition is null ? new List() : */sourceDefinition.Columns.Keys.ToList(); + List exposedColumnNames = GetExposedColumnNames(entityName, sourceDefinition.Columns.Keys.ToList()); // create component for FULL entity with PK. - schemas.Add(entityName, CreateComponentSchema(entityName, fields: columns)); + schemas.Add(entityName, CreateComponentSchema(entityName, fields: exposedColumnNames)); // create component for entity with no PK // get list of columns - primary key columns then optimize foreach (string primaryKeyColumn in sourceDefinition.PrimaryKey) { - columns.Remove(primaryKeyColumn); + if (_metadataProvider.TryGetExposedColumnName(entityName, backingFieldName: primaryKeyColumn, out string? exposedColumnName) + && exposedColumnName is not null) + { + exposedColumnNames.Remove(exposedColumnName); + } } // create component for TABLE (view?) NOT STOREDPROC entity with no PK. DatabaseObject dbo = _metadataProvider.EntityToDatabaseObject[entityName]; if (dbo.SourceType is not SourceType.StoredProcedure or SourceType.View) { - schemas.Add($"{entityName}_NoPK", CreateComponentSchema(entityName, fields: columns)); + schemas.Add($"{entityName}_NoPK", CreateComponentSchema(entityName, fields: exposedColumnNames)); } } @@ -650,6 +658,30 @@ private OpenApiSchema CreateComponentSchema(string entityName, List fiel return schema; } + /// + /// Returns a list of mapped columns names given the input entity and list of unmapped (database) columns. + /// + /// Name of the entity. + /// List of unmapped column names for the entity. + /// List of mapped columns names + private List GetExposedColumnNames(string entityName, IEnumerable unmappedColumnNames) + { + List mappedColumnNames = new(); + + foreach (string dbColumnName in unmappedColumnNames) + { + if (_metadataProvider.TryGetExposedColumnName(entityName, backingFieldName: dbColumnName, out string? exposedColumnName)) + { + if (exposedColumnName is not null) + { + mappedColumnNames.Add(exposedColumnName); + } + } + } + + return mappedColumnNames; + } + /// /// Creates the default collection of responses for all requests in the OpenAPI /// description document. From cf7fbe3ea025c60c022e402d2a3d7af448f6ab79 Mon Sep 17 00:00:00 2001 From: Sean Leonard Date: Fri, 5 May 2023 15:14:35 -0700 Subject: [PATCH 20/30] updated custom REST route handling for swagger and openapi, moved openapi under main restcontroller endpoint, updated logic for openapi, included nextlink --- .../Configuration/ConfigurationTests.cs | 2 +- .../Unittests/RestServiceUnitTests.cs | 21 ++- src/Service/Controllers/OpenApiController.cs | 49 ----- src/Service/Controllers/RestController.cs | 31 +++- src/Service/Services/DbTypeHelper.cs | 65 ------- .../MetadataProviders/SqlMetadataProvider.cs | 4 +- .../Services/OpenAPI/OpenApiDocumentor.cs | 174 ++++++++++-------- .../Services/OpenAPI/SwaggerEndpointMapper.cs | 75 ++++++++ src/Service/Services/RestService.cs | 55 +++--- src/Service/Services/TypeHelper.cs | 123 +++++++++++++ src/Service/Startup.cs | 2 +- 11 files changed, 370 insertions(+), 231 deletions(-) delete mode 100644 src/Service/Controllers/OpenApiController.cs delete mode 100644 src/Service/Services/DbTypeHelper.cs create mode 100644 src/Service/Services/OpenAPI/SwaggerEndpointMapper.cs create mode 100644 src/Service/Services/TypeHelper.cs diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index 89c2ba622f..52ca1c92b1 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -1415,7 +1415,7 @@ public void TestInvalidDatabaseColumnNameHandling( } /// - /// Test different graphql endpoints in different host modes + /// Test different Swagger endpoints in different host modes /// when accessed interactively via browser. /// /// The endpoint route diff --git a/src/Service.Tests/Unittests/RestServiceUnitTests.cs b/src/Service.Tests/Unittests/RestServiceUnitTests.cs index e10eaebb26..0e09097d5e 100644 --- a/src/Service.Tests/Unittests/RestServiceUnitTests.cs +++ b/src/Service.Tests/Unittests/RestServiceUnitTests.cs @@ -27,9 +27,9 @@ public class RestServiceUnitTests #region Positive Cases /// - /// Test the REST Service for parsing entity name - /// and primary key route from the route, given a - /// particular path. + /// Validates that the RestService helper function GetEntityNameAndPrimaryKeyRouteFromRoute + /// properly parses the entity name and primary key route from the route, + /// given the input path (which does not include the path base). /// /// The route to parse. /// The path that the route starts with. @@ -42,14 +42,16 @@ public class RestServiceUnitTests [DataRow("rest api/Book/id/1", "/rest api", "Book", "id/1")] [DataRow(" rest_api/commodities/categoryid/1/pieceid/1", "/ rest_api", "commodities", "categoryid/1/pieceid/1")] [DataRow("rest-api/Book/id/1", "/rest-api", "Book", "id/1")] - public void ParseEntityNameAndPrimaryKeyTest(string route, - string path, - string expectedEntityName, - string expectedPrimaryKeyRoute) + public void ParseEntityNameAndPrimaryKeyTest( + string route, + string path, + string expectedEntityName, + string expectedPrimaryKeyRoute) { InitializeTest(path, expectedEntityName); + string routeAfterPathBase = _restService.GetRouteAfterPathBase(route); (string actualEntityName, string actualPrimaryKeyRoute) = - _restService.GetEntityNameAndPrimaryKeyRouteFromRoute(route); + _restService.GetEntityNameAndPrimaryKeyRouteFromRoute(routeAfterPathBase); Assert.AreEqual(expectedEntityName, actualEntityName); Assert.AreEqual(expectedPrimaryKeyRoute, actualPrimaryKeyRoute); } @@ -76,8 +78,7 @@ public void ErrorForInvalidRouteAndPathToParseTest(string route, InitializeTest(path, route); try { - (string actualEntityName, string actualPrimaryKeyRoute) = - _restService.GetEntityNameAndPrimaryKeyRouteFromRoute(route); + string routeAfterPathBase = _restService.GetRouteAfterPathBase(route); } catch (DataApiBuilderException e) { diff --git a/src/Service/Controllers/OpenApiController.cs b/src/Service/Controllers/OpenApiController.cs deleted file mode 100644 index d56d31f6be..0000000000 --- a/src/Service/Controllers/OpenApiController.cs +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Net.Mime; -using Azure.DataApiBuilder.Service.Services.OpenAPI; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; - -namespace Azure.DataApiBuilder.Service.Controllers -{ - /// - /// Facilitate access to the created OpenAPI description document. - /// - [Route("[controller]")] - [ApiController] - public class OpenApiController : ControllerBase - { - /// - /// OpenAPI description document creation service. - /// - private readonly IOpenApiDocumentor _apiDocumentor; - - public OpenApiController(IOpenApiDocumentor openApiDocumentor) - { - _apiDocumentor = openApiDocumentor; - } - - /// - /// Get the created OpenAPI description document created to represent the possible - /// paths and operations on the DAB engine's REST endpoint. - /// - /// - /// HTTP 200 - Open API description document. - /// HTTP 404 - OpenAPI description document not available since it hasn't been created - /// or failed to be created. - [HttpGet] - [ProducesResponseType(type: typeof(string), statusCode: StatusCodes.Status200OK, contentType: MediaTypeNames.Application.Json)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public IActionResult Get() - { - if (_apiDocumentor.TryGetDocument(out string? document)) - { - return Content(document, MediaTypeNames.Application.Json); - } - - return NotFound(); - } - } -} diff --git a/src/Service/Controllers/RestController.cs b/src/Service/Controllers/RestController.cs index ce3282fcbd..092eda3808 100644 --- a/src/Service/Controllers/RestController.cs +++ b/src/Service/Controllers/RestController.cs @@ -3,10 +3,12 @@ using System; using System.Net; +using System.Net.Mime; using System.Threading.Tasks; using Azure.DataApiBuilder.Service.Exceptions; using Azure.DataApiBuilder.Service.Models; using Azure.DataApiBuilder.Service.Services; +using Azure.DataApiBuilder.Service.Services.OpenAPI; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc; @@ -27,6 +29,11 @@ public class RestController : ControllerBase /// Service providing REST Api executions. /// private readonly RestService _restService; + + /// + /// OpenAPI description document creation service. + /// + private readonly IOpenApiDocumentor _openApiDocumentor; /// /// String representing the value associated with "code" for a server error /// @@ -39,14 +46,20 @@ public class RestController : ControllerBase /// public const string REDIRECTED_ROUTE = "favicon.ico"; + /// + /// OpenAPI route value + /// + public const string OPENAPI_ROUTE = "openapi"; + private readonly ILogger _logger; /// /// Constructor. /// - public RestController(RestService restService, ILogger logger) + public RestController(RestService restService, IOpenApiDocumentor openApiDocumentor, ILogger logger) { _restService = restService; + _openApiDocumentor = openApiDocumentor; _logger = logger; } @@ -86,7 +99,7 @@ public static JsonResult ErrorResponse(string code, string message, HttpStatusCo public async Task Find( string route) { - return await HandleOperation( + return await HandleOperation( route, Config.Operation.Read); } @@ -188,6 +201,20 @@ private async Task HandleOperation( subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest); } + // Validate the PathBase matches the configured REST path. + string routeAfterPathBase = _restService.GetRouteAfterPathBase(route); + + // Explicitly handle OpenAPI description document retrieval requests. + if (string.Equals(routeAfterPathBase, OpenApiDocumentor.OPENAPI_ROUTE, StringComparison.OrdinalIgnoreCase)) + { + if (_openApiDocumentor.TryGetDocument(out string? document)) + { + return Content(document, MediaTypeNames.Application.Json); + } + + return NotFound(); + } + (string entityName, string primaryKeyRoute) = _restService.GetEntityNameAndPrimaryKeyRouteFromRoute(route); IActionResult? result = await _restService.ExecuteAsync(entityName, operationType, primaryKeyRoute); diff --git a/src/Service/Services/DbTypeHelper.cs b/src/Service/Services/DbTypeHelper.cs deleted file mode 100644 index 57a63b6fd1..0000000000 --- a/src/Service/Services/DbTypeHelper.cs +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System; -using System.Collections.Generic; -using System.Data; - -namespace Azure.DataApiBuilder.Service.Services -{ - /// - /// Helper class used to resolve the underlying DbType for the parameter for its given SystemType. - /// - public static class DbTypeHelper - { - private static Dictionary _systemTypeToDbTypeMap = new() - { - [typeof(byte)] = DbType.Byte, - [typeof(sbyte)] = DbType.SByte, - [typeof(short)] = DbType.Int16, - [typeof(ushort)] = DbType.UInt16, - [typeof(int)] = DbType.Int32, - [typeof(uint)] = DbType.UInt32, - [typeof(long)] = DbType.Int64, - [typeof(ulong)] = DbType.UInt64, - [typeof(float)] = DbType.Single, - [typeof(double)] = DbType.Double, - [typeof(decimal)] = DbType.Decimal, - [typeof(bool)] = DbType.Boolean, - [typeof(string)] = DbType.String, - [typeof(char)] = DbType.StringFixedLength, - [typeof(Guid)] = DbType.Guid, - [typeof(byte[])] = DbType.Binary, - [typeof(byte?)] = DbType.Byte, - [typeof(sbyte?)] = DbType.SByte, - [typeof(short?)] = DbType.Int16, - [typeof(ushort?)] = DbType.UInt16, - [typeof(int?)] = DbType.Int32, - [typeof(uint?)] = DbType.UInt32, - [typeof(long?)] = DbType.Int64, - [typeof(ulong?)] = DbType.UInt64, - [typeof(float?)] = DbType.Single, - [typeof(double?)] = DbType.Double, - [typeof(decimal?)] = DbType.Decimal, - [typeof(bool?)] = DbType.Boolean, - [typeof(char?)] = DbType.StringFixedLength, - [typeof(Guid?)] = DbType.Guid, - [typeof(object)] = DbType.Object - }; - - /// - /// Returns the DbType for given system type. - /// - /// The system type for which the DbType is to be determined. - /// DbType for the given system type. - public static DbType? GetDbTypeFromSystemType(Type systemType) - { - if (!_systemTypeToDbTypeMap.TryGetValue(systemType, out DbType dbType)) - { - return null; - } - - return dbType; - } - } -} diff --git a/src/Service/Services/MetadataProviders/SqlMetadataProvider.cs b/src/Service/Services/MetadataProviders/SqlMetadataProvider.cs index 921f8ac1aa..54ebf5cd9b 100644 --- a/src/Service/Services/MetadataProviders/SqlMetadataProvider.cs +++ b/src/Service/Services/MetadataProviders/SqlMetadataProvider.cs @@ -317,7 +317,7 @@ private async Task FillSchemaForStoredProcedureAsync( new() { SystemType = systemType, - DbType = DbTypeHelper.GetDbTypeFromSystemType(systemType) + DbType = TypeHelper.GetDbTypeFromSystemType(systemType) } ); } @@ -1008,7 +1008,7 @@ private async Task PopulateSourceDefinitionAsync( IsNullable = (bool)columnInfoFromAdapter["AllowDBNull"], IsAutoGenerated = (bool)columnInfoFromAdapter["IsAutoIncrement"], SystemType = systemTypeOfColumn, - DbType = DbTypeHelper.GetDbTypeFromSystemType(systemTypeOfColumn) + DbType = TypeHelper.GetDbTypeFromSystemType(systemTypeOfColumn) }; // Tests may try to add the same column simultaneously diff --git a/src/Service/Services/OpenAPI/OpenApiDocumentor.cs b/src/Service/Services/OpenAPI/OpenApiDocumentor.cs index cf9b96edfe..5c1a977105 100644 --- a/src/Service/Services/OpenAPI/OpenApiDocumentor.cs +++ b/src/Service/Services/OpenAPI/OpenApiDocumentor.cs @@ -25,8 +25,8 @@ namespace Azure.DataApiBuilder.Service.Services.OpenAPI /// public class OpenApiDocumentor : IOpenApiDocumentor { - private ISqlMetadataProvider _metadataProvider; - private RuntimeConfig _runtimeConfig; + private readonly ISqlMetadataProvider _metadataProvider; + private readonly RuntimeConfig _runtimeConfig; private OpenApiResponses _defaultOpenApiResponses; private OpenApiDocument? _openApiDocument; @@ -41,9 +41,13 @@ public class OpenApiDocumentor : IOpenApiDocumentor private const string RESPONSE_VALUE_PROPERTY = "value"; private const string RESPONSE_ARRAY_PROPERTY = "array"; + // Routing constant + public const string OPENAPI_ROUTE = "openapi"; + // Error messages public const string DOCUMENT_ALREADY_GENERATED_ERROR = "OpenAPI description document already generated."; public const string DOCUMENT_CREATION_UNSUPPORTED_ERROR = "OpenAPI description document can't be created when the REST endpoint is disabled globally."; + public const string DOCUMENT_CREATION_FAILED_ERROR = "OpenAPI description document creation failed"; /// /// Constructor denotes required services whose metadata is used to generate the OpenAPI description document. @@ -136,7 +140,7 @@ public void CreateDocument() throw new DataApiBuilderException( message: "OpenAPI description document generation failed.", statusCode: HttpStatusCode.InternalServerError, - subStatusCode: DataApiBuilderException.SubStatusCodes.OpenApiDocumentGenerationFailure, + subStatusCode: DataApiBuilderException.SubStatusCodes.OpenApiDocumentCreationFailure, innerException: ex); } } @@ -175,11 +179,11 @@ private OpenApiPaths BuildPaths() } } - // Routes including primary key + // Routes excluding primary key Tuple path = BuildPath(entityName, includePrimaryKeyPathComponent: false); pathsCollection.Add(path.Item1, path.Item2); - // Routes excluding primary key + // Routes including primary key Tuple pathGetAllPost = BuildPath(entityName, includePrimaryKeyPathComponent: true); pathsCollection.TryAdd(pathGetAllPost.Item1, pathGetAllPost.Item2); } @@ -203,12 +207,6 @@ private Tuple BuildPath(string entityName, bool include string entityRestPath = GetEntityRestPath(entityName); string entityBasePathComponent = $"/{entityRestPath}"; - // When the source's primary key(s) are autogenerated, the PUT, PATCH, and POST request - // bodies must not include the primary key(s). - string schemaReferenceId = DoesSourceContainsAutogeneratedPrimaryKey(sourceDefinition) ? $"{entityName}_NoPK" : $"{entityName}"; - - bool requestBodyRequired = IsRequestBodyRequired(sourceDefinition); - // Explicitly exclude setting the tag's Description property since the Name property is self-explanatory. OpenApiTag openApiTag = new() { @@ -228,6 +226,9 @@ private Tuple BuildPath(string entityName, bool include string pkPathComponents = pkComponents.Item1; string fullPathComponent = entityBasePathComponent + pkPathComponents; + /// The OpenApiResponses dictionary key represents the integer value of the HttpStatusCode, + /// which is returned when using Enum.ToString("D"). + /// The "D" format specified "displays the enumeration entry as an integer value in the shortest representation possible." OpenApiOperation getOperation = new() { Description = GETONE_DESCRIPTION, @@ -236,22 +237,29 @@ private Tuple BuildPath(string entityName, bool include }; getOperation.Responses.Add(HttpStatusCode.OK.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.OK), responseObjectSchemaName: entityName)); + // PUT and PATCH requests have the same criteria for decided whether a request body is required. + bool requestBodyRequired = IsRequestBodyRequired(sourceDefinition, considerPrimaryKeys: false); + + // PUT requests must include the primary key(s) in the URI path and exclude from the request body, + // independent of whether the PK(s) are autogenerated. OpenApiOperation putOperation = new() { Description = PUT_DESCRIPTION, Tags = tags, Responses = new(_defaultOpenApiResponses), - RequestBody = CreateOpenApiRequestBodyPayload(schemaReferenceId, requestBodyRequired) + RequestBody = CreateOpenApiRequestBodyPayload($"{entityName}_NoPK", requestBodyRequired) }; putOperation.Responses.Add(HttpStatusCode.OK.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.OK), responseObjectSchemaName: entityName)); putOperation.Responses.Add(HttpStatusCode.Created.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.Created), responseObjectSchemaName: entityName)); + // PATCH requests must include the primary key(s) in the URI path and exclude from the request body, + // independent of whether the PK(s) are autogenerated. OpenApiOperation patchOperation = new() { Description = PATCH_DESCRIPTION, Tags = tags, Responses = new(_defaultOpenApiResponses), - RequestBody = CreateOpenApiRequestBodyPayload(schemaReferenceId, requestBodyRequired) + RequestBody = CreateOpenApiRequestBodyPayload($"{entityName}_NoPK", requestBodyRequired) }; patchOperation.Responses.Add(HttpStatusCode.OK.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.OK), responseObjectSchemaName: entityName)); patchOperation.Responses.Add(HttpStatusCode.Created.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.Created), responseObjectSchemaName: entityName)); @@ -260,8 +268,7 @@ private Tuple BuildPath(string entityName, bool include { Description = DELETE_DESCRIPTION, Tags = tags, - Responses = new(_defaultOpenApiResponses), - RequestBody = CreateOpenApiRequestBodyPayload(schemaReferenceId, requestBodyRequired) + Responses = new(_defaultOpenApiResponses) }; deleteOperation.Responses.Add(HttpStatusCode.NoContent.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.NoContent))); @@ -269,7 +276,7 @@ private Tuple BuildPath(string entityName, bool include { Operations = new Dictionary() { - // Creation GET, POST, PUT, PATCH, DELETE operations + // Creation GET (by ID), PUT, PATCH, DELETE operations [OperationType.Get] = getOperation, [OperationType.Put] = putOperation, [OperationType.Patch] = patchOperation, @@ -282,20 +289,26 @@ private Tuple BuildPath(string entityName, bool include } else { + // Primary key(s) are not included in the URI paths of the GET (all) and POST operations. OpenApiOperation getAllOperation = new() { Description = GETALL_DESCRIPTION, Tags = tags, Responses = new(_defaultOpenApiResponses), }; - getAllOperation.Responses.Add(HttpStatusCode.OK.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.OK), responseObjectSchemaName: entityName)); + getAllOperation.Responses.Add( + HttpStatusCode.OK.ToString("D"), + CreateOpenApiResponse(description: nameof(HttpStatusCode.OK), responseObjectSchemaName: entityName, includeNextLink: true)); + + // - POST requests must include primary key(s) in request body when PK(s) are not autogenerated. + string postBodySchemaReferenceId = DoesSourceContainsAutogeneratedPrimaryKey(sourceDefinition) ? $"{entityName}_NoPK" : $"{entityName}"; OpenApiOperation postOperation = new() { Description = POST_DESCRIPTION, Tags = tags, Responses = new(_defaultOpenApiResponses), - RequestBody = CreateOpenApiRequestBodyPayload(schemaReferenceId, requestBodyRequired) + RequestBody = CreateOpenApiRequestBodyPayload(postBodySchemaReferenceId, IsRequestBodyRequired(sourceDefinition, considerPrimaryKeys: true)) }; postOperation.Responses.Add(HttpStatusCode.Created.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.Created), responseObjectSchemaName: entityName)); postOperation.Responses.Add(HttpStatusCode.Conflict.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.Conflict))); @@ -304,6 +317,7 @@ private Tuple BuildPath(string entityName, bool include { Operations = new Dictionary() { + // Creation GET (all) and POST operations [OperationType.Get] = getAllOperation, [OperationType.Post] = postOperation } @@ -350,13 +364,12 @@ private static OpenApiRequestBody CreateOpenApiRequestBodyPayload(string schemaR } /// - /// Parameters are the placeholders for pk values in curly braces { } in the URL route + /// This function creates the primary key path component string value "/Entity/pk1/{pk1}/pk2/{pk2}" + /// and creates associated parameters which are the placeholders for pk values in curly braces { } in the URL route /// https://localhost:5000/api/Entity/pk1/{pk1}/pk2/{pk2} - /// This function creates the string value "/Entity/pk1/{pk1}/pk2/{pk2}" - /// and creates associated parameters. /// /// Name of the entity. - /// Primary Key path component. Empty string if no primary keys exist on database object source definition. + /// Primary Key path component and associated parameters. Empty string if no primary keys exist on database object source definition. private Tuple> CreatePrimaryKeyPathComponentAndParameters(string entityName) { SourceDefinition sourceDefinition = _metadataProvider.GetSourceDefinition(entityName); @@ -378,7 +391,7 @@ private Tuple> CreatePrimaryKeyPathComponentAndPa { OpenApiSchema parameterSchema = new() { - Type = columnDef is not null ? SystemTypeToJsonDataType(columnDef.SystemType) : string.Empty + Type = columnDef is not null ? TypeHelper.SystemTypeToJsonDataType(columnDef.SystemType) : string.Empty }; OpenApiParameter openApiParameter = new() @@ -426,28 +439,43 @@ private static bool DoesSourceContainsAutogeneratedPrimaryKey(SourceDefinition s /// /// Evaluates a database object's fields to determine whether a request body is required. - /// A body is required when any one field - /// - is auto generated - /// - has a default value - /// - is nullable + /// A request body would typically be included with + /// - POST: primary key(s) considered because they are required to be in the request body when used. + /// - PUT/PATCH: primary key(s) not considered because they are required to be in the request URI when used. + /// A request body is required when any one field + /// - is not auto generated + /// - does not have a default value + /// - is not nullable + /// because a value must be provided for that field. /// /// Database object's source metadata. + /// Whether primary keys should be evaluated against the criteria + /// to require a request body. /// True, when a body should be generated. Otherwise, false. - private static bool IsRequestBodyRequired(SourceDefinition sourceDef) + private static bool IsRequestBodyRequired(SourceDefinition sourceDef, bool considerPrimaryKeys) { + bool requestBodyRequired = false; + foreach (KeyValuePair columnMetadata in sourceDef.Columns) { - // The presence of a non-PK column which does not have any of the following properties - // results in the body being required. - if (columnMetadata.Value.HasDefault || columnMetadata.Value.IsNullable || columnMetadata.Value.IsAutoGenerated) + // Whether to consider primary keys when deciding if a body is required + // because some request bodies may not include primary keys(PUT, PATCH) + // while the (POST) request body does include primary keys (when not autogenerated). + if (sourceDef.PrimaryKey.Contains(columnMetadata.Key) && !considerPrimaryKeys) { continue; } - return false; + // A column which does not have any of the following properties + // results in the body being required so that a value can be provided. + if (!columnMetadata.Value.HasDefault || !columnMetadata.Value.IsNullable || !columnMetadata.Value.IsAutoGenerated) + { + requestBodyRequired = true; + break; + } } - return true; + return requestBodyRequired; } /// @@ -483,7 +511,7 @@ private string GetEntityRestPath(string entityName) /// Schema used to represent response records. /// Null when an example (such as error codes) adds redundant verbosity. /// Base OpenApiResponse object - private static OpenApiResponse CreateOpenApiResponse(string description, string? responseObjectSchemaName = null) + private static OpenApiResponse CreateOpenApiResponse(string description, string? responseObjectSchemaName = null, bool includeNextLink = false) { OpenApiResponse response = new() { @@ -498,7 +526,7 @@ private static OpenApiResponse CreateOpenApiResponse(string description, string? { { MediaTypeNames.Application.Json, - CreateResponseContainer(responseObjectSchemaName) + CreateResponseContainer(responseObjectSchemaName, includeNextLink) } }; response.Content = contentDictionary; @@ -519,7 +547,7 @@ private static OpenApiResponse CreateOpenApiResponse(string description, string? /// /// Schema name of response payload. /// The base response object container. - private static OpenApiMediaType CreateResponseContainer(string responseObjectSchemaName) + private static OpenApiMediaType CreateResponseContainer(string responseObjectSchemaName, bool includeNextLink) { // schema for the response's collection of result records OpenApiSchema resultCollectionSchema = new() @@ -546,6 +574,15 @@ private static OpenApiMediaType CreateResponseContainer(string responseObjectSch } }; + if (includeNextLink) + { + OpenApiSchema nextLinkSchema = new() + { + Type = "string" + }; + responseBodyProperties.Add("nextLink", nextLinkSchema); + } + OpenApiMediaType responsePayload = new() { Schema = new() @@ -569,10 +606,13 @@ private Dictionary CreateComponentSchemas() { Dictionary schemas = new(); - foreach (string entityName in _metadataProvider.EntityToDatabaseObject.Keys.ToList()) + foreach (KeyValuePair entityDbMetadataMap in _metadataProvider.EntityToDatabaseObject) { // Entities which disable their REST endpoint must not be included in // the OpenAPI description document. + string entityName = entityDbMetadataMap.Key; + DatabaseObject dbObject = entityDbMetadataMap.Value; + if (_runtimeConfig.Entities.TryGetValue(entityName, out Entity? entity) && entity is not null) { if (entity.GetRestEnabledOrPathSettings() is bool restEnabled) @@ -587,13 +627,20 @@ private Dictionary CreateComponentSchemas() SourceDefinition sourceDefinition = _metadataProvider.GetSourceDefinition(entityName); List exposedColumnNames = GetExposedColumnNames(entityName, sourceDefinition.Columns.Keys.ToList()); - // create component for FULL entity with PK. + // Create component for FULL entity with PK. schemas.Add(entityName, CreateComponentSchema(entityName, fields: exposedColumnNames)); - // create component for entity with no PK - // get list of columns - primary key columns then optimize + // Create component for entity with no PK used for PUT and PATCH operations because + // they require all primary key references to be in the URI path, not the request body. + // A POST request may foreach (string primaryKeyColumn in sourceDefinition.PrimaryKey) { + // Non-Autogenerated primary key(s) should appear in the request body. + if (!sourceDefinition.Columns[primaryKeyColumn].IsAutoGenerated) + { + continue; + } + if (_metadataProvider.TryGetExposedColumnName(entityName, backingFieldName: primaryKeyColumn, out string? exposedColumnName) && exposedColumnName is not null) { @@ -601,9 +648,8 @@ private Dictionary CreateComponentSchemas() } } - // create component for TABLE (view?) NOT STOREDPROC entity with no PK. - DatabaseObject dbo = _metadataProvider.EntityToDatabaseObject[entityName]; - if (dbo.SourceType is not SourceType.StoredProcedure or SourceType.View) + // Create component for TABLE (view?) NOT STOREDPROC entity with no PK. + if (dbObject.SourceType is not SourceType.StoredProcedure or SourceType.View) { schemas.Add($"{entityName}_NoPK", CreateComponentSchema(entityName, fields: exposedColumnNames)); } @@ -617,16 +663,17 @@ private Dictionary CreateComponentSchemas() /// /// Name of the entity. /// List of mapped (alias) field names. - /// Raised when an entity's database metadata can't be found. + /// Raised when an entity's database metadata can't be found, + /// indicating a failure due to the provided entityName. /// Entity's OpenApiSchema representation. private OpenApiSchema CreateComponentSchema(string entityName, List fields) { if (!_metadataProvider.EntityToDatabaseObject.TryGetValue(entityName, out DatabaseObject? dbObject) || dbObject is null) { throw new DataApiBuilderException( - message: "Entity's database object metadata not found.", + message: $"{DOCUMENT_CREATION_FAILED_ERROR}: Database object metadata not found for the entity {entityName}.", statusCode: HttpStatusCode.InternalServerError, - subStatusCode: DataApiBuilderException.SubStatusCodes.OpenApiDocumentAlreadyExists); + subStatusCode: DataApiBuilderException.SubStatusCodes.OpenApiDocumentCreationFailure); } Dictionary properties = new(); @@ -638,7 +685,7 @@ private OpenApiSchema CreateComponentSchema(string entityName, List fiel string formatMetadata = string.Empty; if (dbObject.SourceDefinition.Columns.TryGetValue(backingColumnValue, out ColumnDefinition? columnDef) && columnDef is not null) { - typeMetadata = SystemTypeToJsonDataType(columnDef.SystemType).ToString().ToLower(); + typeMetadata = TypeHelper.SystemTypeToJsonDataType(columnDef.SystemType).ToString().ToLower(); } properties.Add(field, new OpenApiSchema() @@ -703,38 +750,5 @@ private static OpenApiResponses CreateDefaultOpenApiResponses() return defaultResponses; } - - /// - /// Converts the CLR type to JsonDataType - /// to meet the data type requirement set by the OpenAPI specification. - /// The value returned is formatted for the OpenAPI spec "type" property. - /// - /// CLR type - /// - /// Formatted JSON type name in lower case: e.g. number, string, boolean, etc. - private static string SystemTypeToJsonDataType(Type type) - { - JsonDataType openApiTypeName = type.Name switch - { - "String" => JsonDataType.String, - "Guid" => JsonDataType.String, - "Byte" => JsonDataType.String, - "Int16" => JsonDataType.Number, - "Int32" => JsonDataType.Number, - "Int64" => JsonDataType.Number, - "Single" => JsonDataType.Number, - "Double" => JsonDataType.Number, - "Decimal" => JsonDataType.Number, - "Float" => JsonDataType.Number, - "Boolean" => JsonDataType.Boolean, - "DateTime" => JsonDataType.String, - "DateTimeOffset" => JsonDataType.String, - "Byte[]" => JsonDataType.String, - _ => JsonDataType.Undefined - }; - - string formattedOpenApiTypeName = openApiTypeName.ToString().ToLower(); - return formattedOpenApiTypeName; - } } } diff --git a/src/Service/Services/OpenAPI/SwaggerEndpointMapper.cs b/src/Service/Services/OpenAPI/SwaggerEndpointMapper.cs new file mode 100644 index 0000000000..73e9bf6563 --- /dev/null +++ b/src/Service/Services/OpenAPI/SwaggerEndpointMapper.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections; +using System.Collections.Generic; +using Azure.DataApiBuilder.Config; +using System.Diagnostics.CodeAnalysis; +using Azure.DataApiBuilder.Service.Configurations; +using Swashbuckle.AspNetCore.SwaggerUI; + +namespace Azure.DataApiBuilder.Service.Services.OpenAPI +{ + /// + /// Helper class which returns the endpoint Swagger should use to fetch + /// the OpenAPI description document to accommodate late bound + /// and/or custom REST paths defined in the runtime config. + /// + public class SwaggerEndpointMapper : IEnumerable + { + private readonly RuntimeConfigProvider _runtimeConfigurationProvider; + + // Default configured REST endpoint path used when + // not defined or customized in runtime configuration. + private const string DEFAULT_REST_PATH = "/api"; + + public SwaggerEndpointMapper(RuntimeConfigProvider runtimeConfigurationProvider) + { + _runtimeConfigurationProvider = runtimeConfigurationProvider; + } + + /// + /// Returns an enumerator whose value is the route which Swagger should use to + /// fetch the OpenAPI description document. + /// Format: /{RESTAPIPATH}/openapi + /// The yield return statement is used to return each element of a collection one at a time. + /// When used in a method, it indicates that the method is returning an iterator. + /// + /// Returns a new instance of IEnumerator that iterates over the URIs in the collection. + public IEnumerator GetEnumerator() + { + if (!TryGetRestRouteFromConfig(out string? configuredRestRoute)) + { + configuredRestRoute = DEFAULT_REST_PATH; + } + + yield return new UrlDescriptor { Name = "DataApibuilder-OpenAPI-PREVIEW", Url = $"{configuredRestRoute}/{OpenApiDocumentor.OPENAPI_ROUTE}" }; + } + + /// + /// Explicit implementation of the IEnumerator interface GetEnumerator method. + /// (i.e. its implementation can only be called through a reference of type IEnumerator). + /// + /// Returns a new instance of IEnumerator that iterates over the URIs in the collection. + IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); + + /// + /// When configuration exists and the REST endpoint is enabled, + /// return the configured REST endpoint path. + /// + /// The configured REST route path + /// True when configuredRestRoute is defined, otherwise false. + private bool TryGetRestRouteFromConfig([NotNullWhen(true)] out string? configuredRestRoute) + { + if (_runtimeConfigurationProvider.TryGetRuntimeConfiguration(out RuntimeConfig? config) && + config.RestGlobalSettings.Enabled) + { + configuredRestRoute = config.RestGlobalSettings.Path; + return true; + } + + configuredRestRoute = null; + return false; + } + } +} diff --git a/src/Service/Services/RestService.cs b/src/Service/Services/RestService.cs index d69bb5b8d8..ba1ae73339 100644 --- a/src/Service/Services/RestService.cs +++ b/src/Service/Services/RestService.cs @@ -348,27 +348,22 @@ private bool TryGetStoredProcedureRESTVerbs(string entityName, [NotNullWhen(true } /// - /// Tries to get the Entity name and primary key route - /// from the provided string that starts with the REST - /// path. If the provided string does not start with - /// the given REST path, we throw an exception. We then - /// return the entity name via a lookup using the string - /// up until the next '/' if one exists, and the primary - /// key as the substring following the '/'. For example - /// a request route shoud be of the form - /// {RESTPath}/{EntityPath}/{PKColumn}/{PkValue}/{PKColumn}/{PKValue}... + /// Returns {entity}/{pkName}/{pkValue} from {pathBase}/{entity}/{pkName}/{pkValue} + /// Validates that the {pathBase} value matches the configured REST path. /// - /// The request route, containing REST path + entity path - /// (and optionally primary key). - /// entity name associated with entity path - /// and primary key route. - /// - public (string, string) GetEntityNameAndPrimaryKeyRouteFromRoute(string route) + /// + /// Path proceeding the base without a forward slash. + /// Raised when the routes base + /// does not match the configured REST path. + public string GetRouteAfterPathBase(string route) { - // route will ignore leading '/' so we trim here to allow for restPath - // that start with '/'. We can be assured here that _runtimeConfigProvider.RestPath[0]='/'. - string restPath = _runtimeConfigProvider.RestPath.Substring(1); - if (!route.StartsWith(restPath)) + // configuredRestPathBase will ignore the leading '/' in the runtime configuration REST path. + // so we trim here to allow for restPath + // that start with '/'. + // The RuntimeConfigProvider enforces the expectation that the configured REST path starts with a + // forward slash '/'. + string configuredRestPathBase = _runtimeConfigProvider.RestPath.Substring(1); + if (!route.StartsWith(configuredRestPathBase)) { throw new DataApiBuilderException( message: $"Invalid Path for route: {route}.", @@ -378,7 +373,25 @@ private bool TryGetStoredProcedureRESTVerbs(string entityName, [NotNullWhen(true // entity's path comes after the restPath, so get substring starting from // the end of restPath. If restPath is not empty we trim the '/' following the path. - string routeAfterPath = route.Substring(restPath.Length).TrimStart('/'); + // e.g. Drops /{pathBase}/ from /{pathBase}/{entityName}/{pkName}/{pkValue} + // resulting in: {entityName}/{pkName}/{pkValue} + return route.Substring(configuredRestPathBase.Length).TrimStart('/'); + } + + /// + /// Tries to get the Entity name and primary key route from the provided string + /// returns the entity name via a lookup using the string which includes + /// characters up until the first '/', and then resolves the primary key + /// as the substring following the '/'. + /// For example, a request route shoud be of the form + /// {EntityPath}/{PKColumn}/{PkValue}/{PKColumn}/{PKValue}... + /// + /// The request route (no '/' prefix) containing the entity path + /// (and optionally primary key). + /// entity name associated with entity path and primary key route. + /// + public (string, string) GetEntityNameAndPrimaryKeyRouteFromRoute(string routeAfterPathBase) + { // Split routeAfterPath on the first occurrence of '/', if we get back 2 elements // this means we have a non empty primary key route which we save. Otherwise, save // primary key route as empty string. Entity Path will always be the element at index 0. @@ -386,7 +399,7 @@ private bool TryGetStoredProcedureRESTVerbs(string entityName, [NotNullWhen(true // splits into [{EntityPath}] when there is an empty primary key route and into // [{EntityPath}, {Primarykeyroute}] when there is a non empty primary key route. int maxNumberOfElementsFromSplit = 2; - string[] entityPathAndPKRoute = routeAfterPath.Split(new[] { '/' }, maxNumberOfElementsFromSplit); + string[] entityPathAndPKRoute = routeAfterPathBase.Split(new[] { '/' }, maxNumberOfElementsFromSplit); string entityPath = entityPathAndPKRoute[0]; string primaryKeyRoute = entityPathAndPKRoute.Length == maxNumberOfElementsFromSplit ? entityPathAndPKRoute[1] : string.Empty; diff --git a/src/Service/Services/TypeHelper.cs b/src/Service/Services/TypeHelper.cs new file mode 100644 index 0000000000..7f3d8498f9 --- /dev/null +++ b/src/Service/Services/TypeHelper.cs @@ -0,0 +1,123 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Data; +using Azure.DataApiBuilder.Service.Services.OpenAPI; + +namespace Azure.DataApiBuilder.Service.Services +{ + /// + /// Helper class used to resolve CLR Type to the associated DbType or JsonDataType + /// + public static class TypeHelper + { + private static Dictionary _systemTypeToDbTypeMap = new() + { + [typeof(byte)] = DbType.Byte, + [typeof(sbyte)] = DbType.SByte, + [typeof(short)] = DbType.Int16, + [typeof(ushort)] = DbType.UInt16, + [typeof(int)] = DbType.Int32, + [typeof(uint)] = DbType.UInt32, + [typeof(long)] = DbType.Int64, + [typeof(ulong)] = DbType.UInt64, + [typeof(float)] = DbType.Single, + [typeof(double)] = DbType.Double, + [typeof(decimal)] = DbType.Decimal, + [typeof(bool)] = DbType.Boolean, + [typeof(string)] = DbType.String, + [typeof(char)] = DbType.StringFixedLength, + [typeof(Guid)] = DbType.Guid, + [typeof(byte[])] = DbType.Binary, + [typeof(byte?)] = DbType.Byte, + [typeof(sbyte?)] = DbType.SByte, + [typeof(short?)] = DbType.Int16, + [typeof(ushort?)] = DbType.UInt16, + [typeof(int?)] = DbType.Int32, + [typeof(uint?)] = DbType.UInt32, + [typeof(long?)] = DbType.Int64, + [typeof(ulong?)] = DbType.UInt64, + [typeof(float?)] = DbType.Single, + [typeof(double?)] = DbType.Double, + [typeof(decimal?)] = DbType.Decimal, + [typeof(bool?)] = DbType.Boolean, + [typeof(char?)] = DbType.StringFixedLength, + [typeof(Guid?)] = DbType.Guid, + [typeof(object)] = DbType.Object + }; + + /// + /// Returns the DbType for given system type. + /// + /// The system type for which the DbType is to be determined. + /// DbType for the given system type. + public static DbType? GetDbTypeFromSystemType(Type systemType) + { + if (!_systemTypeToDbTypeMap.TryGetValue(systemType, out DbType dbType)) + { + return null; + } + + return dbType; + } + + /// + /// Enables lookup of JsonDataType given a CLR Type. + /// + private static Dictionary _systemTypeToJsonDataTypeMap = new() + { + [typeof(byte)] = JsonDataType.String, + [typeof(sbyte)] = JsonDataType.String, + [typeof(short)] = JsonDataType.Number, + [typeof(ushort)] = JsonDataType.Number, + [typeof(int)] = JsonDataType.Number, + [typeof(uint)] = JsonDataType.Number, + [typeof(long)] = JsonDataType.Number, + [typeof(ulong)] = JsonDataType.Number, + [typeof(float)] = JsonDataType.Number, + [typeof(double)] = JsonDataType.Number, + [typeof(decimal)] = JsonDataType.Number, + [typeof(bool)] = JsonDataType.Boolean, + [typeof(string)] = JsonDataType.String, + [typeof(char)] = JsonDataType.String, + [typeof(Guid)] = JsonDataType.String, + [typeof(byte[])] = JsonDataType.String, + [typeof(byte?)] = JsonDataType.String, + [typeof(sbyte?)] = JsonDataType.String, + [typeof(short?)] = JsonDataType.Number, + [typeof(ushort?)] = JsonDataType.Number, + [typeof(int?)] = JsonDataType.Number, + [typeof(uint?)] = JsonDataType.Number, + [typeof(long?)] = JsonDataType.Number, + [typeof(ulong?)] = JsonDataType.Number, + [typeof(float?)] = JsonDataType.Number, + [typeof(double?)] = JsonDataType.Number, + [typeof(decimal?)] = JsonDataType.Number, + [typeof(bool?)] = JsonDataType.Boolean, + [typeof(char?)] = JsonDataType.String, + [typeof(Guid?)] = JsonDataType.String, + [typeof(object)] = JsonDataType.Object + }; + + /// + /// Converts the CLR type to JsonDataType + /// to meet the data type requirement set by the OpenAPI specification. + /// The value returned is formatted for the OpenAPI spec "type" property. + /// + /// CLR type + /// + /// Formatted JSON type name in lower case: e.g. number, string, boolean, etc. + public static string SystemTypeToJsonDataType(Type type) + { + if (!_systemTypeToJsonDataTypeMap.TryGetValue(type, out JsonDataType openApiJsonTypeName)) + { + openApiJsonTypeName = JsonDataType.Undefined; + } + + string formattedOpenApiTypeName = openApiJsonTypeName.ToString().ToLower(); + return formattedOpenApiTypeName; + } + } +} diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index 475304c5f3..58d0e54bd0 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -368,7 +368,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, RuntimeC { app.UseSwaggerUI(c => { - c.SwaggerEndpoint("/openapi", "DataApibuilder-OpenAPI-PREVIEW"); + c.ConfigObject.Urls = new SwaggerEndpointMapper(runtimeConfigProvider); }); } From 988faaf72a088c8e46704de139bbf3d1f862311b Mon Sep 17 00:00:00 2001 From: Sean Leonard Date: Fri, 5 May 2023 15:24:45 -0700 Subject: [PATCH 21/30] updated with dotnet format. --- src/Config/DataApiBuilderException.cs | 4 ++-- src/Service/Controllers/RestController.cs | 6 +++--- src/Service/Services/OpenAPI/SwaggerEndpointMapper.cs | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Config/DataApiBuilderException.cs b/src/Config/DataApiBuilderException.cs index 0748d0fa84..8e2d0745d8 100644 --- a/src/Config/DataApiBuilderException.cs +++ b/src/Config/DataApiBuilderException.cs @@ -90,9 +90,9 @@ public enum SubStatusCodes /// OpenApiDocumentAlreadyExists, /// - /// Attempting to generate OpenAPI document failed. + /// Attempt to create OpenAPI document failed. /// - OpenApiDocumentGenerationFailure, + OpenApiDocumentCreationFailure, /// /// Global REST endpoint disabled in runtime configuration. /// diff --git a/src/Service/Controllers/RestController.cs b/src/Service/Controllers/RestController.cs index 092eda3808..7acbbca8df 100644 --- a/src/Service/Controllers/RestController.cs +++ b/src/Service/Controllers/RestController.cs @@ -99,9 +99,9 @@ public static JsonResult ErrorResponse(string code, string message, HttpStatusCo public async Task Find( string route) { - return await HandleOperation( - route, - Config.Operation.Read); + return await HandleOperation( + route, + Config.Operation.Read); } /// diff --git a/src/Service/Services/OpenAPI/SwaggerEndpointMapper.cs b/src/Service/Services/OpenAPI/SwaggerEndpointMapper.cs index 73e9bf6563..41663fc811 100644 --- a/src/Service/Services/OpenAPI/SwaggerEndpointMapper.cs +++ b/src/Service/Services/OpenAPI/SwaggerEndpointMapper.cs @@ -3,8 +3,8 @@ using System.Collections; using System.Collections.Generic; -using Azure.DataApiBuilder.Config; using System.Diagnostics.CodeAnalysis; +using Azure.DataApiBuilder.Config; using Azure.DataApiBuilder.Service.Configurations; using Swashbuckle.AspNetCore.SwaggerUI; From de2026fac5fdc6843e0db4765375b6cb7d876266 Mon Sep 17 00:00:00 2001 From: Sean Leonard Date: Fri, 5 May 2023 16:30:05 -0700 Subject: [PATCH 22/30] fixed tests and controller logic --- .../Configuration/ConfigurationTests.cs | 107 ++++++++++++------ src/Service/Controllers/RestController.cs | 2 +- 2 files changed, 72 insertions(+), 37 deletions(-) diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index 52ca1c92b1..5a9699e2f1 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -21,6 +21,7 @@ using Azure.DataApiBuilder.Service.Resolvers; using Azure.DataApiBuilder.Service.Services; using Azure.DataApiBuilder.Service.Services.MetadataProviders; +using Azure.DataApiBuilder.Service.Services.OpenAPI; using Azure.DataApiBuilder.Service.Tests.Authorization; using Azure.DataApiBuilder.Service.Tests.SqlTests; using HotChocolate; @@ -51,6 +52,8 @@ public class ConfigurationTests private const string CUSTOM_CONFIG_FILENAME = "custom-config.json"; private const string OPENAPI_SWAGGER_ENDPOINT = "swagger"; private const string OPENAPI_DOCUMENT_ENDPOINT = "openapi"; + private const string BROWSER_USER_AGENT_HEADER = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36"; + private const string BROWSER_ACCEPT_HEADER = "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"; private const int RETRY_COUNT = 5; private const int RETRY_WAIT_SECONDS = 1; @@ -400,7 +403,7 @@ public async Task TestSqlSettingPostStartupConfigurations() Assert.AreEqual(HttpStatusCode.ServiceUnavailable, preConfigHydrationResult.StatusCode); HttpResponseMessage preConfigOpenApiDocumentExistence = - await httpClient.GetAsync($"/{OPENAPI_DOCUMENT_ENDPOINT}"); + await httpClient.GetAsync($"/api/{OPENAPI_DOCUMENT_ENDPOINT}"); Assert.AreEqual(HttpStatusCode.ServiceUnavailable, preConfigOpenApiDocumentExistence.StatusCode); // SwaggerUI (OpenAPI user interface) is not made available in production/hosting mode. @@ -431,7 +434,7 @@ public async Task TestSqlSettingPostStartupConfigurations() // OpenAPI document is created during config hydration and // is made available after config hydration completes. HttpResponseMessage postConfigOpenApiDocumentExistence = - await httpClient.GetAsync($"/{OPENAPI_DOCUMENT_ENDPOINT}"); + await httpClient.GetAsync($"/api/{OPENAPI_DOCUMENT_ENDPOINT}"); Assert.AreEqual(HttpStatusCode.OK, postConfigOpenApiDocumentExistence.StatusCode); // SwaggerUI (OpenAPI user interface) is not made available in production/hosting mode. @@ -925,19 +928,20 @@ public async Task TestInteractiveGraphQLEndpoints( $"--ConfigFileName={CUSTOM_CONFIG}" }; - TestServer server = new(Program.CreateWebHostBuilder(args)); - - HttpClient client = server.CreateClient(); - HttpRequestMessage request = new(HttpMethod.Get, endpoint); + using TestServer server = new(Program.CreateWebHostBuilder(args)); + using HttpClient client = server.CreateClient(); + { + HttpRequestMessage request = new(HttpMethod.Get, endpoint); - // Adding the following headers simulates an interactive browser request. - request.Headers.Add("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36"); - request.Headers.Add("accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"); + // Adding the following headers simulates an interactive browser request. + request.Headers.Add("user-agent", BROWSER_USER_AGENT_HEADER); + request.Headers.Add("accept", BROWSER_ACCEPT_HEADER); - HttpResponseMessage response = await client.SendAsync(request); - Assert.AreEqual(expectedStatusCode, response.StatusCode); - string actualBody = await response.Content.ReadAsStringAsync(); - Assert.IsTrue(actualBody.Contains(expectedContent)); + HttpResponseMessage response = await client.SendAsync(request); + Assert.AreEqual(expectedStatusCode, response.StatusCode); + string actualBody = await response.Content.ReadAsStringAsync(); + Assert.IsTrue(actualBody.Contains(expectedContent)); + } } /// @@ -1415,28 +1419,40 @@ public void TestInvalidDatabaseColumnNameHandling( } /// - /// Test different Swagger endpoints in different host modes - /// when accessed interactively via browser. + /// Test different Swagger endpoints in different host modes when accessed interactively via browser. + /// Two pass request scheme: + /// 1 - Send get request to expected Swagger endpoint /swagger + /// Response - Internally Swagger sends HTTP 301 Moved Permanently with Location header + /// pointing to exact Swagger page (/swagger/index.html) + /// 2 - Send GET request to path referred to by Location header in previous response + /// Response - Successful loading of SwaggerUI HTML, with reference to endpoint used + /// to retrieve OpenAPI document. This test ensures that Swagger components load, but + /// does not confirm that a proper OpenAPI document was created. /// - /// The endpoint route + /// The custom REST route /// The mode in which the service is executing. /// Expected Status Code. - /// The expected phrase in the response body. + /// Snippet of expected HTML to be emitted from successful page load. + /// This should note the openapi route that Swagger will use to retrieve the OpenAPI document. [DataTestMethod] [TestCategory(TestCategory.MSSQL)] - [DataRow("/swagger", HostModeType.Development, HttpStatusCode.OK, "", DisplayName = "SwaggerUI enabled in development mode.")] - [DataRow("/swagger", HostModeType.Production, HttpStatusCode.BadRequest, "", DisplayName = "SwaggerUI disabled in production mode.")] + [DataRow("/api", HostModeType.Development, false, HttpStatusCode.OK, "{\"urls\":[{\"url\":\"/api/openapi\"", DisplayName = "SwaggerUI enabled in development mode.")] + [DataRow("/custompath", HostModeType.Development, false, HttpStatusCode.OK, "{\"urls\":[{\"url\":\"/custompath/openapi\"", DisplayName = "SwaggerUI enabled with custom REST path in development mode.")] + [DataRow("/api", HostModeType.Production, true, HttpStatusCode.BadRequest, "", DisplayName = "SwaggerUI disabled in production mode.")] + [DataRow("/custompath", HostModeType.Production, true, HttpStatusCode.BadRequest, "", DisplayName = "SwaggerUI disabled in production mode with custom REST path.")] public async Task OpenApi_InteractiveSwaggerUI( - string endpoint, + string customRestPath, HostModeType hostModeType, + bool expectsError, HttpStatusCode expectedStatusCode, - string expectedContent = "") + string expectedOpenApiTargetContent) { + string swaggerEndpoint = "/swagger"; Dictionary settings = new() { { GlobalSettingsType.Host, JsonSerializer.SerializeToElement(new HostGlobalSettings(){ Mode = hostModeType}) }, { GlobalSettingsType.GraphQL, JsonSerializer.SerializeToElement(new GraphQLGlobalSettings(){ Enabled = true }) }, - { GlobalSettingsType.Rest, JsonSerializer.SerializeToElement(new RestGlobalSettings(){ Enabled = true }) } + { GlobalSettingsType.Rest, JsonSerializer.SerializeToElement(new RestGlobalSettings(){ Enabled = true, Path = customRestPath }) } }; DataSource dataSource = new(DatabaseType.mssql) @@ -1455,19 +1471,35 @@ public async Task OpenApi_InteractiveSwaggerUI( $"--ConfigFileName={CUSTOM_CONFIG}" }; - TestServer server = new(Program.CreateWebHostBuilder(args)); + using (TestServer server = new(Program.CreateWebHostBuilder(args))) + using (HttpClient client = server.CreateClient()) + { + HttpRequestMessage initialRequest = new(HttpMethod.Get, swaggerEndpoint); - HttpClient client = server.CreateClient(); - HttpRequestMessage request = new(HttpMethod.Get, endpoint); + // Adding the following headers simulates an interactive browser request. + initialRequest.Headers.Add("user-agent", BROWSER_USER_AGENT_HEADER); + initialRequest.Headers.Add("accept", BROWSER_ACCEPT_HEADER); + + HttpResponseMessage response = await client.SendAsync(initialRequest); + if (expectsError) + { + // Swagger endpoint internally configured to reroute from /swagger to /swagger/index.html + Assert.AreEqual(expectedStatusCode, response.StatusCode); + } + else + { + // Swagger endpoint internally configured to reroute from /swagger to /swagger/index.html + Assert.AreEqual(HttpStatusCode.MovedPermanently, response.StatusCode); - // Adding the following headers simulates an interactive browser request. - request.Headers.Add("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36"); - request.Headers.Add("accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"); + HttpRequestMessage followUpRequest = new(HttpMethod.Get, response.Headers.Location); + HttpResponseMessage followUpResponse = await client.SendAsync(followUpRequest); + Assert.AreEqual(expectedStatusCode, followUpResponse.StatusCode); - HttpResponseMessage response = await client.SendAsync(request); - Assert.AreEqual(expectedStatusCode, response.StatusCode); - string actualBody = await response.Content.ReadAsStringAsync(); - Assert.IsTrue(actualBody.Contains(expectedContent)); + // Validate that Swagger requests OpenAPI document using REST path defined in runtime config. + string actualBody = await followUpResponse.Content.ReadAsStringAsync(); + Assert.AreEqual(true, actualBody.Contains(expectedOpenApiTargetContent)); + } + } } /// @@ -1511,10 +1543,8 @@ public async Task OpenApi_GlobalEntityRestPath(bool globalRestEnabled, bool expe using (HttpClient client = server.CreateClient()) { // Setup and send GET request - HttpRequestMessage readOpenApiDocumentRequest = new(HttpMethod.Get, "/openapi"); + HttpRequestMessage readOpenApiDocumentRequest = new(HttpMethod.Get, $"/api/{OPENAPI_DOCUMENT_ENDPOINT}"); HttpResponseMessage response = await client.SendAsync(readOpenApiDocumentRequest); - string responseBody = await response.Content.ReadAsStringAsync(); - Dictionary responseProperties = JsonSerializer.Deserialize>(responseBody); // Validate response if (expectsError) @@ -1523,6 +1553,11 @@ public async Task OpenApi_GlobalEntityRestPath(bool globalRestEnabled, bool expe } else { + // Process response body + string responseBody = await response.Content.ReadAsStringAsync(); + Dictionary responseProperties = JsonSerializer.Deserialize>(responseBody); + + // Validate response body Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); ValidateOpenApiDocTopLevelPropertiesExist(responseProperties); } @@ -1573,7 +1608,7 @@ public async Task OpenApi_EntityLevelRestEndpoint() using (HttpClient client = server.CreateClient()) { // Setup and send GET request - HttpRequestMessage readOpenApiDocumentRequest = new(HttpMethod.Get, "/openapi"); + HttpRequestMessage readOpenApiDocumentRequest = new(HttpMethod.Get, $"/api/{OpenApiDocumentor.OPENAPI_ROUTE}"); HttpResponseMessage response = await client.SendAsync(readOpenApiDocumentRequest); // Parse response metadata diff --git a/src/Service/Controllers/RestController.cs b/src/Service/Controllers/RestController.cs index 7acbbca8df..2638534658 100644 --- a/src/Service/Controllers/RestController.cs +++ b/src/Service/Controllers/RestController.cs @@ -215,7 +215,7 @@ private async Task HandleOperation( return NotFound(); } - (string entityName, string primaryKeyRoute) = _restService.GetEntityNameAndPrimaryKeyRouteFromRoute(route); + (string entityName, string primaryKeyRoute) = _restService.GetEntityNameAndPrimaryKeyRouteFromRoute(routeAfterPathBase); IActionResult? result = await _restService.ExecuteAsync(entityName, operationType, primaryKeyRoute); From f1e06b92b32825a80885d2d72d784c0af58d9379 Mon Sep 17 00:00:00 2001 From: Sean Leonard Date: Fri, 5 May 2023 16:35:24 -0700 Subject: [PATCH 23/30] list to hashset to track columns --- src/Service/Services/OpenAPI/OpenApiDocumentor.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Service/Services/OpenAPI/OpenApiDocumentor.cs b/src/Service/Services/OpenAPI/OpenApiDocumentor.cs index 5c1a977105..ddfa5dba99 100644 --- a/src/Service/Services/OpenAPI/OpenApiDocumentor.cs +++ b/src/Service/Services/OpenAPI/OpenApiDocumentor.cs @@ -625,7 +625,7 @@ private Dictionary CreateComponentSchemas() } SourceDefinition sourceDefinition = _metadataProvider.GetSourceDefinition(entityName); - List exposedColumnNames = GetExposedColumnNames(entityName, sourceDefinition.Columns.Keys.ToList()); + HashSet exposedColumnNames = GetExposedColumnNames(entityName, sourceDefinition.Columns.Keys.ToList()); // Create component for FULL entity with PK. schemas.Add(entityName, CreateComponentSchema(entityName, fields: exposedColumnNames)); @@ -666,7 +666,7 @@ private Dictionary CreateComponentSchemas() /// Raised when an entity's database metadata can't be found, /// indicating a failure due to the provided entityName. /// Entity's OpenApiSchema representation. - private OpenApiSchema CreateComponentSchema(string entityName, List fields) + private OpenApiSchema CreateComponentSchema(string entityName, HashSet fields) { if (!_metadataProvider.EntityToDatabaseObject.TryGetValue(entityName, out DatabaseObject? dbObject) || dbObject is null) { @@ -711,9 +711,9 @@ private OpenApiSchema CreateComponentSchema(string entityName, List fiel /// Name of the entity. /// List of unmapped column names for the entity. /// List of mapped columns names - private List GetExposedColumnNames(string entityName, IEnumerable unmappedColumnNames) + private HashSet GetExposedColumnNames(string entityName, IEnumerable unmappedColumnNames) { - List mappedColumnNames = new(); + HashSet mappedColumnNames = new(); foreach (string dbColumnName in unmappedColumnNames) { From f77b30445175c86b8927ce14d915fa831d43b9d4 Mon Sep 17 00:00:00 2001 From: Sean Leonard Date: Fri, 5 May 2023 16:55:27 -0700 Subject: [PATCH 24/30] allow building notice file in dev. --- .pipelines/build-pipelines.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pipelines/build-pipelines.yml b/.pipelines/build-pipelines.yml index 06d93f21b1..c937457e93 100644 --- a/.pipelines/build-pipelines.yml +++ b/.pipelines/build-pipelines.yml @@ -113,7 +113,7 @@ steps: - task: notice@0 # This will generate the NOTICE.txt for distribution displayName: Generate NOTICE file - condition: and(succeeded(), eq(variables.isReleaseBuild, 'true')) + condition: and(succeeded()) inputs: outputfile: $(Build.SourcesDirectory)/NOTICE.txt outputformat: text From aaaee4d812d26288c3eb0d3a19f1cbb370e919e8 Mon Sep 17 00:00:00 2001 From: Sean Leonard Date: Fri, 5 May 2023 17:00:06 -0700 Subject: [PATCH 25/30] allow building notice file remove conditions. --- .pipelines/build-pipelines.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.pipelines/build-pipelines.yml b/.pipelines/build-pipelines.yml index c937457e93..e0882bec77 100644 --- a/.pipelines/build-pipelines.yml +++ b/.pipelines/build-pipelines.yml @@ -113,7 +113,6 @@ steps: - task: notice@0 # This will generate the NOTICE.txt for distribution displayName: Generate NOTICE file - condition: and(succeeded()) inputs: outputfile: $(Build.SourcesDirectory)/NOTICE.txt outputformat: text From 32da9c4b546121fd7664e523a41110746ce000a5 Mon Sep 17 00:00:00 2001 From: Sean Leonard Date: Tue, 9 May 2023 11:45:29 -0700 Subject: [PATCH 26/30] updated tests, updated restservice/controller handling of swaggerendpoint, updated openapidocumentor handling of stored procedures and post requests to tables with auto-gen/non-autogen pk consideration in body. --- .../Configuration/ConfigurationTests.cs | 5 +- src/Service/Controllers/RestController.cs | 5 - .../Services/OpenAPI/OpenApiDocumentor.cs | 420 +++++++++++++----- .../Services/OpenAPI/SwaggerEndpointMapper.cs | 36 +- src/Service/Services/RestService.cs | 50 ++- src/Service/Startup.cs | 3 +- 6 files changed, 356 insertions(+), 163 deletions(-) diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index 5a9699e2f1..f511d96262 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -1431,6 +1431,7 @@ public void TestInvalidDatabaseColumnNameHandling( /// /// The custom REST route /// The mode in which the service is executing. + /// Whether to expect an error. /// Expected Status Code. /// Snippet of expected HTML to be emitted from successful page load. /// This should note the openapi route that Swagger will use to retrieve the OpenAPI document. @@ -1483,7 +1484,9 @@ public async Task OpenApi_InteractiveSwaggerUI( HttpResponseMessage response = await client.SendAsync(initialRequest); if (expectsError) { - // Swagger endpoint internally configured to reroute from /swagger to /swagger/index.html + // Redirect(HTTP 301) and follow up request to the returned path + // do not occur in a failure scenario. Only HTTP 400 (Bad Request) + // is expected. Assert.AreEqual(expectedStatusCode, response.StatusCode); } else diff --git a/src/Service/Controllers/RestController.cs b/src/Service/Controllers/RestController.cs index 2638534658..58e615ad13 100644 --- a/src/Service/Controllers/RestController.cs +++ b/src/Service/Controllers/RestController.cs @@ -46,11 +46,6 @@ public class RestController : ControllerBase /// public const string REDIRECTED_ROUTE = "favicon.ico"; - /// - /// OpenAPI route value - /// - public const string OPENAPI_ROUTE = "openapi"; - private readonly ILogger _logger; /// diff --git a/src/Service/Services/OpenAPI/OpenApiDocumentor.cs b/src/Service/Services/OpenAPI/OpenApiDocumentor.cs index ddfa5dba99..8a6fbfd2d2 100644 --- a/src/Service/Services/OpenAPI/OpenApiDocumentor.cs +++ b/src/Service/Services/OpenAPI/OpenApiDocumentor.cs @@ -38,6 +38,7 @@ public class OpenApiDocumentor : IOpenApiDocumentor private const string PUT_DESCRIPTION = "Replace or create entity."; private const string PATCH_DESCRIPTION = "Update or create entity."; private const string DELETE_DESCRIPTION = "Delete entity."; + private const string SP_EXECUTE_DESCRIPTION = "Executes a stored procedure."; private const string RESPONSE_VALUE_PROPERTY = "value"; private const string RESPONSE_ARRAY_PROPERTY = "array"; @@ -164,8 +165,17 @@ private OpenApiPaths BuildPaths() { OpenApiPaths pathsCollection = new(); - foreach (string entityName in _metadataProvider.EntityToDatabaseObject.Keys.ToList()) + foreach (KeyValuePair entityDbMetadataMap in _metadataProvider.EntityToDatabaseObject) { + // Entities which disable their REST endpoint must not be included in + // the OpenAPI description document. + string entityName = entityDbMetadataMap.Key; + string entityRestPath = GetEntityRestPath(entityName); + string entityBasePathComponent = $"/{entityRestPath}"; + + DatabaseObject dbObject = entityDbMetadataMap.Value; + SourceDefinition sourceDefinition = _metadataProvider.GetSourceDefinition(entityName); + // Entities which disable their REST endpoint must not be included in // the OpenAPI description document. if (_runtimeConfig.Entities.TryGetValue(entityName, out Entity? entity) && entity is not null) @@ -179,152 +189,308 @@ private OpenApiPaths BuildPaths() } } - // Routes excluding primary key - Tuple path = BuildPath(entityName, includePrimaryKeyPathComponent: false); - pathsCollection.Add(path.Item1, path.Item2); + // Explicitly exclude setting the tag's Description property since the Name property is self-explanatory. + OpenApiTag openApiTag = new() + { + Name = entityRestPath + }; + + // The OpenApiTag will categorize all paths created using the entity's name or overridden REST path value. + // The tag categorization will instruct OpenAPI document visualization tooling to display all generated paths together. + List tags = new() + { + openApiTag + }; + + Dictionary configuredRestOperations = GetConfiguredRestOperations(entityName, dbObject); + + if (dbObject.SourceType is SourceType.StoredProcedure) + { + Dictionary operations = CreateStoredProcedureOperations( + entityName: entityName, + sourceDefinition: sourceDefinition, + configuredRestOperations: configuredRestOperations, + tags: tags); + + OpenApiPathItem openApiPathItem = new() + { + Operations = operations + }; + + pathsCollection.TryAdd(entityBasePathComponent, openApiPathItem); + } + else + { + // Create operations for SourceType.Table and SourceType.View + // Operations including primary key + Dictionary pkOperations = CreateOperations( + entityName: entityName, + sourceDefinition: sourceDefinition, + includePrimaryKeyPathComponent: true, + tags: tags); + + Tuple> pkComponents = CreatePrimaryKeyPathComponentAndParameters(entityName); + string pkPathComponents = pkComponents.Item1; + string fullPathComponent = entityBasePathComponent + pkPathComponents; + + OpenApiPathItem openApiPkPathItem = new() + { + Operations = pkOperations, + Parameters = pkComponents.Item2 + }; + + pathsCollection.TryAdd(fullPathComponent, openApiPkPathItem); + + // Operations excluding primary key + Dictionary operations = CreateOperations( + entityName: entityName, + sourceDefinition: sourceDefinition, + includePrimaryKeyPathComponent: false, + tags: tags); + + OpenApiPathItem openApiPathItem = new() + { + Operations = operations + }; - // Routes including primary key - Tuple pathGetAllPost = BuildPath(entityName, includePrimaryKeyPathComponent: true); - pathsCollection.TryAdd(pathGetAllPost.Item1, pathGetAllPost.Item2); + pathsCollection.TryAdd(entityBasePathComponent, openApiPathItem); + } } return pathsCollection; } /// - /// Includes Path with Operations(+responses) and Parameters - /// Parameters are the placeholders for pk values in curly braces { } in the URL route - /// localhost:5000/api/Entity/pk1/{pk1}/pk2/{pk2} - /// more generically: - /// /entityName OR /RestPathName followed by (/pk/{pkValue}) * Number of primary keys. + /// Creates OpenApiOperation definitions for entities with SourceType.Table/View /// - /// Tuple of the path value: e.g. "/Entity/pk1/{pk1}" - /// and path metadata. - private Tuple BuildPath(string entityName, bool includePrimaryKeyPathComponent) + /// Name of the entity + /// Database object information + /// Whether to create operations which will be mapped to + /// a path containing primary key parameters. + /// TRUE: GET (one), PUT, PATCH, DELETE + /// FALSE: GET (Many), POST + /// Tags denoting how the operations should be categorized. + /// Typically one tag value, the entity's REST path. + /// Collection of operation types and associated definitions. + private Dictionary CreateOperations( + string entityName, + SourceDefinition sourceDefinition, + bool includePrimaryKeyPathComponent, + List tags) { - SourceDefinition sourceDefinition = _metadataProvider.GetSourceDefinition(entityName); - - string entityRestPath = GetEntityRestPath(entityName); - string entityBasePathComponent = $"/{entityRestPath}"; - - // Explicitly exclude setting the tag's Description property since the Name property is self-explanatory. - OpenApiTag openApiTag = new() - { - Name = entityRestPath - }; - - // The OpenApiTag will categorize all paths created using the entity's name or overridden REST path value. - // The tag categorization will instruct OpenAPI document visualization tooling to display all generated paths together. - List tags = new() - { - openApiTag - }; + Dictionary openApiPathItemOperations = new(); if (includePrimaryKeyPathComponent) { - Tuple> pkComponents = CreatePrimaryKeyPathComponentAndParameters(entityName); - string pkPathComponents = pkComponents.Item1; - string fullPathComponent = entityBasePathComponent + pkPathComponents; - - /// The OpenApiResponses dictionary key represents the integer value of the HttpStatusCode, - /// which is returned when using Enum.ToString("D"). - /// The "D" format specified "displays the enumeration entry as an integer value in the shortest representation possible." - OpenApiOperation getOperation = new() - { - Description = GETONE_DESCRIPTION, - Tags = tags, - Responses = new(_defaultOpenApiResponses), - }; + // The OpenApiResponses dictionary key represents the integer value of the HttpStatusCode, + // which is returned when using Enum.ToString("D"). + // The "D" format specified "displays the enumeration entry as an integer value in the shortest representation possible." + OpenApiOperation getOperation = CreateBaseOperation(description: GETONE_DESCRIPTION, tags: tags); getOperation.Responses.Add(HttpStatusCode.OK.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.OK), responseObjectSchemaName: entityName)); + openApiPathItemOperations.Add(OperationType.Get, getOperation); // PUT and PATCH requests have the same criteria for decided whether a request body is required. bool requestBodyRequired = IsRequestBodyRequired(sourceDefinition, considerPrimaryKeys: false); // PUT requests must include the primary key(s) in the URI path and exclude from the request body, // independent of whether the PK(s) are autogenerated. - OpenApiOperation putOperation = new() - { - Description = PUT_DESCRIPTION, - Tags = tags, - Responses = new(_defaultOpenApiResponses), - RequestBody = CreateOpenApiRequestBodyPayload($"{entityName}_NoPK", requestBodyRequired) - }; + OpenApiOperation putOperation = CreateBaseOperation(description: PUT_DESCRIPTION, tags: tags); + putOperation.RequestBody = CreateOpenApiRequestBodyPayload($"{entityName}_NoPK", requestBodyRequired); putOperation.Responses.Add(HttpStatusCode.OK.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.OK), responseObjectSchemaName: entityName)); putOperation.Responses.Add(HttpStatusCode.Created.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.Created), responseObjectSchemaName: entityName)); + openApiPathItemOperations.Add(OperationType.Put, putOperation); // PATCH requests must include the primary key(s) in the URI path and exclude from the request body, // independent of whether the PK(s) are autogenerated. - OpenApiOperation patchOperation = new() - { - Description = PATCH_DESCRIPTION, - Tags = tags, - Responses = new(_defaultOpenApiResponses), - RequestBody = CreateOpenApiRequestBodyPayload($"{entityName}_NoPK", requestBodyRequired) - }; + OpenApiOperation patchOperation = CreateBaseOperation(description: PATCH_DESCRIPTION, tags: tags); + patchOperation.RequestBody = CreateOpenApiRequestBodyPayload($"{entityName}_NoPK", requestBodyRequired); patchOperation.Responses.Add(HttpStatusCode.OK.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.OK), responseObjectSchemaName: entityName)); patchOperation.Responses.Add(HttpStatusCode.Created.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.Created), responseObjectSchemaName: entityName)); + openApiPathItemOperations.Add(OperationType.Patch, patchOperation); - OpenApiOperation deleteOperation = new() - { - Description = DELETE_DESCRIPTION, - Tags = tags, - Responses = new(_defaultOpenApiResponses) - }; + OpenApiOperation deleteOperation = CreateBaseOperation(description: DELETE_DESCRIPTION, tags: tags); deleteOperation.Responses.Add(HttpStatusCode.NoContent.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.NoContent))); + openApiPathItemOperations.Add(OperationType.Delete, deleteOperation); - OpenApiPathItem openApiPathItem = new() - { - Operations = new Dictionary() - { - // Creation GET (by ID), PUT, PATCH, DELETE operations - [OperationType.Get] = getOperation, - [OperationType.Put] = putOperation, - [OperationType.Patch] = patchOperation, - [OperationType.Delete] = deleteOperation - }, - Parameters = pkComponents.Item2 - }; - - return new(fullPathComponent, openApiPathItem); + return openApiPathItemOperations; } else { // Primary key(s) are not included in the URI paths of the GET (all) and POST operations. - OpenApiOperation getAllOperation = new() - { - Description = GETALL_DESCRIPTION, - Tags = tags, - Responses = new(_defaultOpenApiResponses), - }; + OpenApiOperation getAllOperation = CreateBaseOperation(description: GETALL_DESCRIPTION, tags: tags); getAllOperation.Responses.Add( HttpStatusCode.OK.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.OK), responseObjectSchemaName: entityName, includeNextLink: true)); + openApiPathItemOperations.Add(OperationType.Get, getAllOperation); // - POST requests must include primary key(s) in request body when PK(s) are not autogenerated. - string postBodySchemaReferenceId = DoesSourceContainsAutogeneratedPrimaryKey(sourceDefinition) ? $"{entityName}_NoPK" : $"{entityName}"; + string postBodySchemaReferenceId = DoesSourceContainAutogeneratedPrimaryKey(sourceDefinition) ? $"{entityName}_NoAutoPK" : $"{entityName}"; - OpenApiOperation postOperation = new() - { - Description = POST_DESCRIPTION, - Tags = tags, - Responses = new(_defaultOpenApiResponses), - RequestBody = CreateOpenApiRequestBodyPayload(postBodySchemaReferenceId, IsRequestBodyRequired(sourceDefinition, considerPrimaryKeys: true)) - }; + OpenApiOperation postOperation = CreateBaseOperation(description: POST_DESCRIPTION, tags: tags); + postOperation.RequestBody = CreateOpenApiRequestBodyPayload(postBodySchemaReferenceId, IsRequestBodyRequired(sourceDefinition, considerPrimaryKeys: true)); postOperation.Responses.Add(HttpStatusCode.Created.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.Created), responseObjectSchemaName: entityName)); postOperation.Responses.Add(HttpStatusCode.Conflict.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.Conflict))); + openApiPathItemOperations.Add(OperationType.Post, postOperation); - OpenApiPathItem openApiPathItem = new() + return openApiPathItemOperations; + } + } + + /// + /// Creates OpenApiOperation definitions for entities with SourceType.StoredProcedure + /// + /// Entity name. + /// Database object information. + /// Collection of which operations should be created for the stored procedure. + /// Tags denoting how the operations should be categorized. + /// Typically one tag value, the entity's REST path. + /// Collection of operation types and associated definitions. + private Dictionary CreateStoredProcedureOperations( + string entityName, + SourceDefinition sourceDefinition, + Dictionary configuredRestOperations, + List tags) + { + Dictionary openApiPathItemOperations = new(); + + if (configuredRestOperations[OperationType.Get]) + { + OpenApiOperation getOperation = CreateBaseOperation(description: SP_EXECUTE_DESCRIPTION, tags: tags); + getOperation.Responses.Add( + HttpStatusCode.OK.ToString("D"), + CreateOpenApiResponse( + description: nameof(HttpStatusCode.OK), + responseObjectSchemaName: entityName, + includeNextLink: false)); + openApiPathItemOperations.Add(OperationType.Get, getOperation); + } + + if (configuredRestOperations[OperationType.Post]) + { + // POST requests for stored procedure entities must include primary key(s) in request body. + OpenApiOperation postOperation = CreateBaseOperation(description: SP_EXECUTE_DESCRIPTION, tags: tags); + postOperation.RequestBody = CreateOpenApiRequestBodyPayload($"{entityName}", IsRequestBodyRequired(sourceDefinition, considerPrimaryKeys: true)); + postOperation.Responses.Add(HttpStatusCode.Created.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.Created), responseObjectSchemaName: entityName)); + postOperation.Responses.Add(HttpStatusCode.Conflict.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.Conflict))); + openApiPathItemOperations.Add(OperationType.Post, postOperation); + } + + // PUT and PATCH requests have the same criteria for decided whether a request body is required. + bool requestBodyRequired = IsRequestBodyRequired(sourceDefinition, considerPrimaryKeys: false); + + if (configuredRestOperations[OperationType.Put]) + { + // PUT requests for stored procedure entities must include primary key(s) in request body. + OpenApiOperation putOperation = CreateBaseOperation(description: SP_EXECUTE_DESCRIPTION, tags: tags); + putOperation.RequestBody = CreateOpenApiRequestBodyPayload($"{entityName}", requestBodyRequired); + putOperation.Responses.Add(HttpStatusCode.OK.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.OK), responseObjectSchemaName: entityName)); + putOperation.Responses.Add(HttpStatusCode.Created.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.Created), responseObjectSchemaName: entityName)); + openApiPathItemOperations.Add(OperationType.Put, putOperation); + } + + if (configuredRestOperations[OperationType.Patch]) + { + // PATCH requests for stored procedure entities must include primary key(s) in request body + OpenApiOperation patchOperation = CreateBaseOperation(description: SP_EXECUTE_DESCRIPTION, tags: tags); + patchOperation.RequestBody = CreateOpenApiRequestBodyPayload($"{entityName}", requestBodyRequired); + patchOperation.Responses.Add(HttpStatusCode.OK.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.OK), responseObjectSchemaName: entityName)); + patchOperation.Responses.Add(HttpStatusCode.Created.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.Created), responseObjectSchemaName: entityName)); + openApiPathItemOperations.Add(OperationType.Patch, patchOperation); + } + + if (configuredRestOperations[OperationType.Delete]) + { + OpenApiOperation deleteOperation = CreateBaseOperation(description: SP_EXECUTE_DESCRIPTION, tags: tags); + deleteOperation.Responses.Add(HttpStatusCode.NoContent.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.NoContent))); + openApiPathItemOperations.Add(OperationType.Delete, deleteOperation); + } + + return openApiPathItemOperations; + } + + /// + /// Creates an OpenApiOperation object pre-populated with common properties used + /// across all operation types (GET (one/all), POST, PUT, PATCH, DELETE) + /// + /// Description of the operation. + /// Tags defining how to categorize the operation in the OpenAPI document. + /// OpenApiOperation + private OpenApiOperation CreateBaseOperation(string description, List tags) + { + OpenApiOperation operation = new() + { + Description = description, + Tags = tags, + Responses = new(_defaultOpenApiResponses) + }; + + return operation; + } + + /// + /// Returns collection of OpenAPI OperationTypes and associated flag indicating whether they are enabled + /// for the engine's REST endpoint. + /// Acts as a helper for stored procedures where the runtime config can denote any combination of REST verbs + /// to enable. + /// + /// Name of the entity. + /// Database object metadata, indicating entity SourceType + /// Collection of OpenAPI OperationTypes and whether they should be created. + private Dictionary GetConfiguredRestOperations(string entityName, DatabaseObject dbObject) + { + Dictionary configuredOperations = new() + { + [OperationType.Get] = false, + [OperationType.Post] = false, + [OperationType.Put] = false, + [OperationType.Patch] = false, + [OperationType.Delete] = false + }; + + if (dbObject.SourceType == SourceType.StoredProcedure) + { + Entity entityTest = _runtimeConfig.Entities[entityName]; + List? spRestMethods = entityTest.GetRestMethodsConfiguredForStoredProcedure()?.ToList(); + + if (spRestMethods is null) + { + return configuredOperations; + } + + foreach (RestMethod restMethod in spRestMethods) { - Operations = new Dictionary() + switch (restMethod) { - // Creation GET (all) and POST operations - [OperationType.Get] = getAllOperation, - [OperationType.Post] = postOperation + case RestMethod.Get: + configuredOperations[OperationType.Get] = true; + break; + case RestMethod.Post: + configuredOperations[OperationType.Post] = true; + break; + case RestMethod.Put: + configuredOperations[OperationType.Put] = true; + break; + case RestMethod.Patch: + configuredOperations[OperationType.Patch] = true; + break; + case RestMethod.Delete: + configuredOperations[OperationType.Delete] = true; + break; + default: + break; } - }; - - return new(entityBasePathComponent, openApiPathItem); + } } + else + { + configuredOperations[OperationType.Get] = true; + configuredOperations[OperationType.Post] = true; + configuredOperations[OperationType.Put] = true; + configuredOperations[OperationType.Patch] = true; + configuredOperations[OperationType.Delete] = true; + } + + return configuredOperations; } /// @@ -419,7 +585,7 @@ private Tuple> CreatePrimaryKeyPathComponentAndPa /// /// Database object metadata. /// True, when the primary key is autogenerated. Otherwise, false. - private static bool DoesSourceContainsAutogeneratedPrimaryKey(SourceDefinition sourceDefinition) + private static bool DoesSourceContainAutogeneratedPrimaryKey(SourceDefinition sourceDefinition) { bool sourceObjectHasAutogeneratedPK = false; // Create primary key path component. @@ -598,7 +764,8 @@ private static OpenApiMediaType CreateResponseContainer(string responseObjectSch /// Builds the schema objects for all entities present in the runtime configuration. /// Two schemas per entity are created: /// 1) {EntityName} -> Primary keys present in schema, used for request bodies (excluding GET) and all response bodies. - /// 2) {EntityName}_NoPK -> No primary keys present in schema, used for POST requests where PK is autogenerated and GET (all). + /// 2) {EntityName}_NoAutoPK -> No auto-generated primary keys present in schema, used for POST requests where PK is not autogenerated and GET (all). + /// 3) {EntityName}_NoPK -> No primary keys present in schema, used for POST requests where PK is autogenerated and GET (all). /// Schema objects can be referenced elsewhere in the OpenAPI document with the intent to reduce document verbosity. /// /// Collection of schemas for entities defined in the runtime configuration. @@ -626,31 +793,48 @@ private Dictionary CreateComponentSchemas() SourceDefinition sourceDefinition = _metadataProvider.GetSourceDefinition(entityName); HashSet exposedColumnNames = GetExposedColumnNames(entityName, sourceDefinition.Columns.Keys.ToList()); + HashSet autoGeneratedPKColumnNames = new(); - // Create component for FULL entity with PK. + // Create component schema for FULL entity with all primary key columns (included auto-generated) + // which will typically represent the response body of a request or a stored procedure's request body. schemas.Add(entityName, CreateComponentSchema(entityName, fields: exposedColumnNames)); - // Create component for entity with no PK used for PUT and PATCH operations because - // they require all primary key references to be in the URI path, not the request body. - // A POST request may - foreach (string primaryKeyColumn in sourceDefinition.PrimaryKey) + if (dbObject.SourceType is not SourceType.StoredProcedure) { - // Non-Autogenerated primary key(s) should appear in the request body. - if (!sourceDefinition.Columns[primaryKeyColumn].IsAutoGenerated) + // Create an entity's request body component schema excluding autogenerated primary keys. + // A POST request requires any non-autogenerated primary key references to be in the request body. + foreach (string primaryKeyColumn in sourceDefinition.PrimaryKey) { - continue; + // Non-Autogenerated primary key(s) should appear in the request body. + if (!sourceDefinition.Columns[primaryKeyColumn].IsAutoGenerated) + { + autoGeneratedPKColumnNames.Add(primaryKeyColumn); + continue; + } + + if (_metadataProvider.TryGetExposedColumnName(entityName, backingFieldName: primaryKeyColumn, out string? exposedColumnName) + && exposedColumnName is not null) + { + exposedColumnNames.Remove(exposedColumnName); + } } - if (_metadataProvider.TryGetExposedColumnName(entityName, backingFieldName: primaryKeyColumn, out string? exposedColumnName) - && exposedColumnName is not null) + schemas.Add($"{entityName}_NoAutoPK", CreateComponentSchema(entityName, fields: exposedColumnNames)); + + // Create an entity's request body component schema excluding all primary keys + // by removing the tracked autogenerated primary key column names and removing them from + // the exposedColumnNames collection. + // The schema component without primary keys is used for PUT and PATCH operation request bodies because + // those operations require all primary key references to be in the URI path, not the request body. + foreach (string primaryKeyColumn in autoGeneratedPKColumnNames) { - exposedColumnNames.Remove(exposedColumnName); + if (_metadataProvider.TryGetExposedColumnName(entityName, backingFieldName: primaryKeyColumn, out string? exposedColumnName) + && exposedColumnName is not null) + { + exposedColumnNames.Remove(exposedColumnName); + } } - } - // Create component for TABLE (view?) NOT STOREDPROC entity with no PK. - if (dbObject.SourceType is not SourceType.StoredProcedure or SourceType.View) - { schemas.Add($"{entityName}_NoPK", CreateComponentSchema(entityName, fields: exposedColumnNames)); } } diff --git a/src/Service/Services/OpenAPI/SwaggerEndpointMapper.cs b/src/Service/Services/OpenAPI/SwaggerEndpointMapper.cs index 41663fc811..1855e0ad4b 100644 --- a/src/Service/Services/OpenAPI/SwaggerEndpointMapper.cs +++ b/src/Service/Services/OpenAPI/SwaggerEndpointMapper.cs @@ -3,9 +3,6 @@ using System.Collections; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using Azure.DataApiBuilder.Config; -using Azure.DataApiBuilder.Service.Configurations; using Swashbuckle.AspNetCore.SwaggerUI; namespace Azure.DataApiBuilder.Service.Services.OpenAPI @@ -17,15 +14,21 @@ namespace Azure.DataApiBuilder.Service.Services.OpenAPI /// public class SwaggerEndpointMapper : IEnumerable { - private readonly RuntimeConfigProvider _runtimeConfigurationProvider; + private readonly RestService? _restService; // Default configured REST endpoint path used when // not defined or customized in runtime configuration. private const string DEFAULT_REST_PATH = "/api"; - public SwaggerEndpointMapper(RuntimeConfigProvider runtimeConfigurationProvider) + /// + /// Constructor to setup required services + /// + /// RestService contains helpers and references to runtime + /// configuration which return the configured REST path. Will be null during late + /// bound config, so returns default REST path for SwaggerUI. + public SwaggerEndpointMapper(RestService? restService) { - _runtimeConfigurationProvider = runtimeConfigurationProvider; + _restService = restService; } /// @@ -38,7 +41,7 @@ public SwaggerEndpointMapper(RuntimeConfigProvider runtimeConfigurationProvider) /// Returns a new instance of IEnumerator that iterates over the URIs in the collection. public IEnumerator GetEnumerator() { - if (!TryGetRestRouteFromConfig(out string? configuredRestRoute)) + if (_restService is null || !_restService.TryGetRestRouteFromConfig(out string? configuredRestRoute)) { configuredRestRoute = DEFAULT_REST_PATH; } @@ -52,24 +55,5 @@ public IEnumerator GetEnumerator() /// /// Returns a new instance of IEnumerator that iterates over the URIs in the collection. IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); - - /// - /// When configuration exists and the REST endpoint is enabled, - /// return the configured REST endpoint path. - /// - /// The configured REST route path - /// True when configuredRestRoute is defined, otherwise false. - private bool TryGetRestRouteFromConfig([NotNullWhen(true)] out string? configuredRestRoute) - { - if (_runtimeConfigurationProvider.TryGetRuntimeConfiguration(out RuntimeConfig? config) && - config.RestGlobalSettings.Enabled) - { - configuredRestRoute = config.RestGlobalSettings.Path; - return true; - } - - configuredRestRoute = null; - return false; - } } } diff --git a/src/Service/Services/RestService.cs b/src/Service/Services/RestService.cs index ba1ae73339..f04c50b3ba 100644 --- a/src/Service/Services/RestService.cs +++ b/src/Service/Services/RestService.cs @@ -348,21 +348,30 @@ private bool TryGetStoredProcedureRESTVerbs(string entityName, [NotNullWhen(true } /// - /// Returns {entity}/{pkName}/{pkValue} from {pathBase}/{entity}/{pkName}/{pkValue} + /// Input route: {pathBase}/{entity}/{pkName}/{pkValue} /// Validates that the {pathBase} value matches the configured REST path. + /// Returns {entity}/{pkName}/{pkValue} after stripping {pathBase} + /// and the proceding slash /. /// - /// - /// Path proceeding the base without a forward slash. - /// Raised when the routes base - /// does not match the configured REST path. + /// {pathBase}/{entity}/{pkName}/{pkValue} with no starting '/'. + /// Route without pathBase and without a forward slash. + /// Raised when the routes path base + /// does not match the configured REST path or the global REST endpoint is disabled. public string GetRouteAfterPathBase(string route) { - // configuredRestPathBase will ignore the leading '/' in the runtime configuration REST path. - // so we trim here to allow for restPath - // that start with '/'. + if (!TryGetRestRouteFromConfig(out string? configuredRestPathBase)) + { + throw new DataApiBuilderException( + message: $"The global REST endpoint is disabled.", + statusCode: HttpStatusCode.BadRequest, + subStatusCode: DataApiBuilderException.SubStatusCodes.GlobalRestEndpointDisabled); + } + + // Strip the leading '/' from the REST path provided in the runtime configuration + // because the input argument 'route' has no starting '/'. // The RuntimeConfigProvider enforces the expectation that the configured REST path starts with a // forward slash '/'. - string configuredRestPathBase = _runtimeConfigProvider.RestPath.Substring(1); + configuredRestPathBase = configuredRestPathBase.Substring(1); if (!route.StartsWith(configuredRestPathBase)) { throw new DataApiBuilderException( @@ -371,13 +380,30 @@ public string GetRouteAfterPathBase(string route) subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest); } - // entity's path comes after the restPath, so get substring starting from - // the end of restPath. If restPath is not empty we trim the '/' following the path. - // e.g. Drops /{pathBase}/ from /{pathBase}/{entityName}/{pkName}/{pkValue} + // Drop {pathBase}/ from {pathBase}/{entityName}/{pkName}/{pkValue} // resulting in: {entityName}/{pkName}/{pkValue} return route.Substring(configuredRestPathBase.Length).TrimStart('/'); } + /// + /// When configuration exists and the REST endpoint is enabled, + /// return the configured REST endpoint path. + /// + /// The configured REST route path + /// True when configuredRestRoute is defined, otherwise false. + public bool TryGetRestRouteFromConfig([NotNullWhen(true)] out string? configuredRestRoute) + { + if (_runtimeConfigProvider.TryGetRuntimeConfiguration(out RuntimeConfig? config) && + config.RestGlobalSettings.Enabled) + { + configuredRestRoute = config.RestGlobalSettings.Path; + return true; + } + + configuredRestRoute = null; + return false; + } + /// /// Tries to get the Entity name and primary key route from the provided string /// returns the entity name via a lookup using the string which includes diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index 58d0e54bd0..5e889b6798 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -364,11 +364,12 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, RuntimeC // SwaggerUI visualization of the OpenAPI description document is only available // in developer mode in alignment with the restriction placed on ChilliCream's BananaCakePop IDE. + // Consequently, SwaggerUI is not presented in a StaticWebApps (late-bound config) environment. if (runtimeConfigProvider.IsDeveloperMode() || env.IsDevelopment()) { app.UseSwaggerUI(c => { - c.ConfigObject.Urls = new SwaggerEndpointMapper(runtimeConfigProvider); + c.ConfigObject.Urls = new SwaggerEndpointMapper(app.ApplicationServices.GetService()); }); } From 6aee9307ed3c372afab320c57c70f601db6eb31e Mon Sep 17 00:00:00 2001 From: Sean Leonard Date: Tue, 9 May 2023 11:53:38 -0700 Subject: [PATCH 27/30] added back condition to notice file generation task in ci/cd pipeline --- .pipelines/build-pipelines.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.pipelines/build-pipelines.yml b/.pipelines/build-pipelines.yml index e0882bec77..06d93f21b1 100644 --- a/.pipelines/build-pipelines.yml +++ b/.pipelines/build-pipelines.yml @@ -113,6 +113,7 @@ steps: - task: notice@0 # This will generate the NOTICE.txt for distribution displayName: Generate NOTICE file + condition: and(succeeded(), eq(variables.isReleaseBuild, 'true')) inputs: outputfile: $(Build.SourcesDirectory)/NOTICE.txt outputformat: text From d25b15b6ed4e609b8c5ee6e3e76b4aefa359707c Mon Sep 17 00:00:00 2001 From: Sean Leonard Date: Tue, 9 May 2023 13:44:02 -0700 Subject: [PATCH 28/30] updated logic and tests for RestPath retrieval to use existing functions --- .../Services/OpenAPI/SwaggerEndpointMapper.cs | 25 +++++++------------ src/Service/Services/RestService.cs | 8 +----- src/Service/Startup.cs | 2 +- 3 files changed, 11 insertions(+), 24 deletions(-) diff --git a/src/Service/Services/OpenAPI/SwaggerEndpointMapper.cs b/src/Service/Services/OpenAPI/SwaggerEndpointMapper.cs index 1855e0ad4b..c93cef4e00 100644 --- a/src/Service/Services/OpenAPI/SwaggerEndpointMapper.cs +++ b/src/Service/Services/OpenAPI/SwaggerEndpointMapper.cs @@ -3,6 +3,8 @@ using System.Collections; using System.Collections.Generic; +using Azure.DataApiBuilder.Config; +using Azure.DataApiBuilder.Service.Configurations; using Swashbuckle.AspNetCore.SwaggerUI; namespace Azure.DataApiBuilder.Service.Services.OpenAPI @@ -14,21 +16,16 @@ namespace Azure.DataApiBuilder.Service.Services.OpenAPI /// public class SwaggerEndpointMapper : IEnumerable { - private readonly RestService? _restService; - - // Default configured REST endpoint path used when - // not defined or customized in runtime configuration. - private const string DEFAULT_REST_PATH = "/api"; + private readonly RuntimeConfigProvider? _runtimeConfigProvider; /// /// Constructor to setup required services /// - /// RestService contains helpers and references to runtime - /// configuration which return the configured REST path. Will be null during late - /// bound config, so returns default REST path for SwaggerUI. - public SwaggerEndpointMapper(RestService? restService) + /// RuntimeConfigProvider contains the reference to the + /// configured REST path. Will be empty during late bound config, so returns default REST path for SwaggerUI. + public SwaggerEndpointMapper(RuntimeConfigProvider? runtimeConfigProvider) { - _restService = restService; + _runtimeConfigProvider = runtimeConfigProvider; } /// @@ -41,12 +38,8 @@ public SwaggerEndpointMapper(RestService? restService) /// Returns a new instance of IEnumerator that iterates over the URIs in the collection. public IEnumerator GetEnumerator() { - if (_restService is null || !_restService.TryGetRestRouteFromConfig(out string? configuredRestRoute)) - { - configuredRestRoute = DEFAULT_REST_PATH; - } - - yield return new UrlDescriptor { Name = "DataApibuilder-OpenAPI-PREVIEW", Url = $"{configuredRestRoute}/{OpenApiDocumentor.OPENAPI_ROUTE}" }; + string configuredRestPath = _runtimeConfigProvider?.RestPath ?? GlobalSettings.REST_DEFAULT_PATH; + yield return new UrlDescriptor { Name = "DataApibuilder-OpenAPI-PREVIEW", Url = $"{configuredRestPath}/{OpenApiDocumentor.OPENAPI_ROUTE}" }; } /// diff --git a/src/Service/Services/RestService.cs b/src/Service/Services/RestService.cs index f04c50b3ba..8a8e0ee13d 100644 --- a/src/Service/Services/RestService.cs +++ b/src/Service/Services/RestService.cs @@ -359,13 +359,7 @@ private bool TryGetStoredProcedureRESTVerbs(string entityName, [NotNullWhen(true /// does not match the configured REST path or the global REST endpoint is disabled. public string GetRouteAfterPathBase(string route) { - if (!TryGetRestRouteFromConfig(out string? configuredRestPathBase)) - { - throw new DataApiBuilderException( - message: $"The global REST endpoint is disabled.", - statusCode: HttpStatusCode.BadRequest, - subStatusCode: DataApiBuilderException.SubStatusCodes.GlobalRestEndpointDisabled); - } + string configuredRestPathBase = _runtimeConfigProvider.RestPath; // Strip the leading '/' from the REST path provided in the runtime configuration // because the input argument 'route' has no starting '/'. diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index 5e889b6798..cb67942bb7 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -369,7 +369,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, RuntimeC { app.UseSwaggerUI(c => { - c.ConfigObject.Urls = new SwaggerEndpointMapper(app.ApplicationServices.GetService()); + c.ConfigObject.Urls = new SwaggerEndpointMapper(app.ApplicationServices.GetService()); }); } From dd3ead5a8ebf8382ce11dd01cde707c6b12f43e9 Mon Sep 17 00:00:00 2001 From: Sean Leonard Date: Tue, 9 May 2023 13:49:23 -0700 Subject: [PATCH 29/30] reuse constants for default rest path /api --- src/Service.Tests/Configuration/ConfigurationTests.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index f511d96262..9b0b71d456 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -403,7 +403,7 @@ public async Task TestSqlSettingPostStartupConfigurations() Assert.AreEqual(HttpStatusCode.ServiceUnavailable, preConfigHydrationResult.StatusCode); HttpResponseMessage preConfigOpenApiDocumentExistence = - await httpClient.GetAsync($"/api/{OPENAPI_DOCUMENT_ENDPOINT}"); + await httpClient.GetAsync($"{GlobalSettings.REST_DEFAULT_PATH}/{OPENAPI_DOCUMENT_ENDPOINT}"); Assert.AreEqual(HttpStatusCode.ServiceUnavailable, preConfigOpenApiDocumentExistence.StatusCode); // SwaggerUI (OpenAPI user interface) is not made available in production/hosting mode. @@ -434,7 +434,7 @@ public async Task TestSqlSettingPostStartupConfigurations() // OpenAPI document is created during config hydration and // is made available after config hydration completes. HttpResponseMessage postConfigOpenApiDocumentExistence = - await httpClient.GetAsync($"/api/{OPENAPI_DOCUMENT_ENDPOINT}"); + await httpClient.GetAsync($"{GlobalSettings.REST_DEFAULT_PATH}/{OPENAPI_DOCUMENT_ENDPOINT}"); Assert.AreEqual(HttpStatusCode.OK, postConfigOpenApiDocumentExistence.StatusCode); // SwaggerUI (OpenAPI user interface) is not made available in production/hosting mode. @@ -1546,7 +1546,7 @@ public async Task OpenApi_GlobalEntityRestPath(bool globalRestEnabled, bool expe using (HttpClient client = server.CreateClient()) { // Setup and send GET request - HttpRequestMessage readOpenApiDocumentRequest = new(HttpMethod.Get, $"/api/{OPENAPI_DOCUMENT_ENDPOINT}"); + HttpRequestMessage readOpenApiDocumentRequest = new(HttpMethod.Get, $"{GlobalSettings.REST_DEFAULT_PATH}/{OPENAPI_DOCUMENT_ENDPOINT}"); HttpResponseMessage response = await client.SendAsync(readOpenApiDocumentRequest); // Validate response @@ -1611,7 +1611,7 @@ public async Task OpenApi_EntityLevelRestEndpoint() using (HttpClient client = server.CreateClient()) { // Setup and send GET request - HttpRequestMessage readOpenApiDocumentRequest = new(HttpMethod.Get, $"/api/{OpenApiDocumentor.OPENAPI_ROUTE}"); + HttpRequestMessage readOpenApiDocumentRequest = new(HttpMethod.Get, $"{GlobalSettings.REST_DEFAULT_PATH}/{OpenApiDocumentor.OPENAPI_ROUTE}"); HttpResponseMessage response = await client.SendAsync(readOpenApiDocumentRequest); // Parse response metadata From 834615d84a1910a0698a3537ce525bfa2f2e1fd1 Mon Sep 17 00:00:00 2001 From: Sean Leonard Date: Wed, 10 May 2023 15:40:46 -0700 Subject: [PATCH 30/30] updated hashset name to nonAutoGeneratedPKColumnNames --- src/Service/Services/OpenAPI/OpenApiDocumentor.cs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/Service/Services/OpenAPI/OpenApiDocumentor.cs b/src/Service/Services/OpenAPI/OpenApiDocumentor.cs index 8a6fbfd2d2..64cc9c1173 100644 --- a/src/Service/Services/OpenAPI/OpenApiDocumentor.cs +++ b/src/Service/Services/OpenAPI/OpenApiDocumentor.cs @@ -323,7 +323,10 @@ private Dictionary CreateOperations( CreateOpenApiResponse(description: nameof(HttpStatusCode.OK), responseObjectSchemaName: entityName, includeNextLink: true)); openApiPathItemOperations.Add(OperationType.Get, getAllOperation); - // - POST requests must include primary key(s) in request body when PK(s) are not autogenerated. + // The POST body must include fields for primary key(s) which are not autogenerated because a value must be supplied + // for those fields. {entityName}_NoAutoPK represents the schema component which has all fields except for autogenerated primary keys. + // When no autogenerated primary keys exist, then all fields can be included in the POST body which is represented by the schema + // component: {entityName}. string postBodySchemaReferenceId = DoesSourceContainAutogeneratedPrimaryKey(sourceDefinition) ? $"{entityName}_NoAutoPK" : $"{entityName}"; OpenApiOperation postOperation = CreateBaseOperation(description: POST_DESCRIPTION, tags: tags); @@ -793,7 +796,7 @@ private Dictionary CreateComponentSchemas() SourceDefinition sourceDefinition = _metadataProvider.GetSourceDefinition(entityName); HashSet exposedColumnNames = GetExposedColumnNames(entityName, sourceDefinition.Columns.Keys.ToList()); - HashSet autoGeneratedPKColumnNames = new(); + HashSet nonAutoGeneratedPKColumnNames = new(); // Create component schema for FULL entity with all primary key columns (included auto-generated) // which will typically represent the response body of a request or a stored procedure's request body. @@ -808,7 +811,7 @@ private Dictionary CreateComponentSchemas() // Non-Autogenerated primary key(s) should appear in the request body. if (!sourceDefinition.Columns[primaryKeyColumn].IsAutoGenerated) { - autoGeneratedPKColumnNames.Add(primaryKeyColumn); + nonAutoGeneratedPKColumnNames.Add(primaryKeyColumn); continue; } @@ -822,11 +825,11 @@ private Dictionary CreateComponentSchemas() schemas.Add($"{entityName}_NoAutoPK", CreateComponentSchema(entityName, fields: exposedColumnNames)); // Create an entity's request body component schema excluding all primary keys - // by removing the tracked autogenerated primary key column names and removing them from + // by removing the tracked non-autogenerated primary key column names and removing them from // the exposedColumnNames collection. // The schema component without primary keys is used for PUT and PATCH operation request bodies because // those operations require all primary key references to be in the URI path, not the request body. - foreach (string primaryKeyColumn in autoGeneratedPKColumnNames) + foreach (string primaryKeyColumn in nonAutoGeneratedPKColumnNames) { if (_metadataProvider.TryGetExposedColumnName(entityName, backingFieldName: primaryKeyColumn, out string? exposedColumnName) && exposedColumnName is not null)