diff --git a/src/Config/DataApiBuilderException.cs b/src/Config/DataApiBuilderException.cs index 6eb19590b7..674837bedd 100644 --- a/src/Config/DataApiBuilderException.cs +++ b/src/Config/DataApiBuilderException.cs @@ -85,7 +85,19 @@ public enum SubStatusCodes /// /// Error encountered while doing data type conversions. /// - ErrorProcessingData + ErrorProcessingData, + /// + /// Attempting to generate OpenAPI document when one already exists. + /// + OpenApiDocumentAlreadyExists, + /// + /// Attempt to create OpenAPI document failed. + /// + OpenApiDocumentCreationFailure, + /// + /// Global REST endpoint disabled in runtime configuration. + /// + GlobalRestEndpointDisabled } public HttpStatusCode StatusCode { get; } diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index ca85a0bf3e..2935063848 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -17,13 +17,14 @@ - + + @@ -32,6 +33,7 @@ + diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index 5a2d489059..de3fbc2c22 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,12 @@ 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 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; @@ -374,9 +381,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($"{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. + HttpResponseMessage preConfigOpenApiSwaggerEndpointAvailability = + await httpClient.GetAsync($"/{OPENAPI_SWAGGER_ENDPOINT}"); + Assert.AreEqual(HttpStatusCode.ServiceUnavailable, preConfigOpenApiSwaggerEndpointAvailability.StatusCode); HttpStatusCode responseCode = await HydratePostStartupConfiguration(httpClient, config); @@ -397,6 +413,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($"{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. + // 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)] @@ -882,19 +911,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)); + } } /// @@ -1371,6 +1401,276 @@ public void TestInvalidDatabaseColumnNameHandling( } } + /// + /// 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 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. + [DataTestMethod] + [TestCategory(TestCategory.MSSQL)] + [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 customRestPath, + HostModeType hostModeType, + bool expectsError, + HttpStatusCode expectedStatusCode, + 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, Path = customRestPath }) } + }; + + 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()) + { + HttpRequestMessage initialRequest = new(HttpMethod.Get, swaggerEndpoint); + + // 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) + { + // 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 + { + // Swagger endpoint internally configured to reroute from /swagger to /swagger/index.html + Assert.AreEqual(HttpStatusCode.MovedPermanently, response.StatusCode); + + HttpRequestMessage followUpRequest = new(HttpMethod.Get, response.Headers.Location); + HttpResponseMessage followUpResponse = await client.SendAsync(followUpRequest); + Assert.AreEqual(expectedStatusCode, followUpResponse.StatusCode); + + // 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)); + } + } + } + + /// + /// Validates the OpenAPI documentor behavior when enabling and disabling the global REST endpoint + /// for the DAB engine. + /// Global REST enabled: + /// - 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")] + [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) + { + // 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); + + Dictionary entityMap = new() + { + { "Book", requiredEntity } + }; + + CreateCustomConfigFile(globalRestEnabled: globalRestEnabled, entityMap); + + string[] args = new[] + { + $"--ConfigFileName={CUSTOM_CONFIG_FILENAME}" + }; + + using (TestServer server = new(Program.CreateWebHostBuilder(args))) + using (HttpClient client = server.CreateClient()) + { + // Setup and send GET request + HttpRequestMessage readOpenApiDocumentRequest = new(HttpMethod.Get, $"{GlobalSettings.REST_DEFAULT_PATH}/{OPENAPI_DOCUMENT_ENDPOINT}"); + HttpResponseMessage response = await client.SendAsync(readOpenApiDocumentRequest); + + // Validate response + if (expectsError) + { + Assert.AreEqual(HttpStatusCode.NotFound, response.StatusCode); + } + 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); + } + } + } + + /// + /// 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() + { + // Create the entities under test. + 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); + + 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); + + Dictionary entityMap = new() + { + { "Book", restEnabledEntity }, + { "Publisher", restDisabledEntity } + }; + + CreateCustomConfigFile(globalRestEnabled: true, entityMap); + + string[] args = new[] + { + $"--ConfigFileName={CUSTOM_CONFIG_FILENAME}" + }; + + using (TestServer server = new(Program.CreateWebHostBuilder(args))) + using (HttpClient client = server.CreateClient()) + { + // Setup and send GET request + HttpRequestMessage readOpenApiDocumentRequest = new(HttpMethod.Get, $"{GlobalSettings.REST_DEFAULT_PATH}/{OpenApiDocumentor.OPENAPI_ROUTE}"); + 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 _)); + } + } + + /// + /// 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. + /// + /// 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. /// @@ -1574,12 +1874,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.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/Azure.DataApiBuilder.Service.csproj b/src/Service/Azure.DataApiBuilder.Service.csproj index eb6f90d370..199ed12994 100644 --- a/src/Service/Azure.DataApiBuilder.Service.csproj +++ b/src/Service/Azure.DataApiBuilder.Service.csproj @@ -58,6 +58,7 @@ + @@ -67,6 +68,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/Service/Controllers/RestController.cs b/src/Service/Controllers/RestController.cs index ce3282fcbd..58e615ad13 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 /// @@ -44,9 +51,10 @@ public class RestController : ControllerBase /// /// Constructor. /// - public RestController(RestService restService, ILogger logger) + public RestController(RestService restService, IOpenApiDocumentor openApiDocumentor, ILogger logger) { _restService = restService; + _openApiDocumentor = openApiDocumentor; _logger = logger; } @@ -87,8 +95,8 @@ public async Task Find( string route) { return await HandleOperation( - route, - Config.Operation.Read); + route, + Config.Operation.Read); } /// @@ -188,7 +196,21 @@ private async Task HandleOperation( subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest); } - (string entityName, string primaryKeyRoute) = _restService.GetEntityNameAndPrimaryKeyRouteFromRoute(route); + // 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(routeAfterPathBase); 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 65b4af87ae..5af1e545e4 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) } ); } @@ -1016,7 +1016,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/IOpenApiDocumentor.cs b/src/Service/Services/OpenAPI/IOpenApiDocumentor.cs new file mode 100644 index 0000000000..7876a507b8 --- /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 +{ + /// + /// 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 the 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..517a1d0c67 --- /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, + /// + /// A JSON Object + /// + Object = 1, + /// + /// A JSON array + /// + Array = 2, + /// + /// A JSON string + /// + String = 3, + /// + /// A JSON number + /// + Number = 4, + /// + /// A JSON Boolean + /// + Boolean = 5, + /// + /// The JSON value null + /// + Null = 6 + } +} diff --git a/src/Service/Services/OpenAPI/OpenApiDocumentor.cs b/src/Service/Services/OpenAPI/OpenApiDocumentor.cs new file mode 100644 index 0000000000..64cc9c1173 --- /dev/null +++ b/src/Service/Services/OpenAPI/OpenApiDocumentor.cs @@ -0,0 +1,941 @@ +// 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.Net.Mime; +using System.Text; +using Azure.DataApiBuilder.Config; +using Azure.DataApiBuilder.Service.Configurations; +using Azure.DataApiBuilder.Service.Exceptions; +using Microsoft.OpenApi.Models; +using Microsoft.OpenApi.Writers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.DataApiBuilder.Service.Services.OpenAPI +{ + /// + /// Service which generates and provides the OpenAPI description document + /// describing the DAB engine's entity REST endpoint paths. + /// + public class OpenApiDocumentor : IOpenApiDocumentor + { + private readonly ISqlMetadataProvider _metadataProvider; + private readonly RuntimeConfig _runtimeConfig; + 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 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 SP_EXECUTE_DESCRIPTION = "Executes a stored procedure."; + 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. + /// + /// Provides database object metadata. + /// Provides entity/REST path metadata. + public OpenApiDocumentor(ISqlMetadataProvider sqlMetadataProvider, RuntimeConfigProvider runtimeConfigProvider) + { + _metadataProvider = sqlMetadataProvider; + _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) + { + 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; + } + } + + /// + /// 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() + { + if (_openApiDocument is not null) + { + throw new DataApiBuilderException( + 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.MethodNotAllowed, + subStatusCode: DataApiBuilderException.SubStatusCodes.GlobalRestEndpointDisabled); + } + + try + { + string restEndpointPath = _runtimeConfig.RestGlobalSettings.Path; + OpenApiComponents components = new() + { + Schemas = CreateComponentSchemas() + }; + + OpenApiDocument doc = new() + { + Info = new OpenApiInfo + { + Version = DOCUMENTOR_VERSION, + Title = DOCUMENTOR_UI_TITLE, + }, + Servers = new List + { + new OpenApiServer { Url = $"{restEndpointPath}" } + }, + Paths = BuildPaths(), + Components = components + }; + _openApiDocument = doc; + } + catch (Exception ex) + { + throw new DataApiBuilderException( + message: "OpenAPI description document generation failed.", + statusCode: HttpStatusCode.InternalServerError, + subStatusCode: DataApiBuilderException.SubStatusCodes.OpenApiDocumentCreationFailure, + innerException: ex); + } + } + + /// + /// Iterates through the runtime configuration's entities and generates the path object + /// representing the DAB engine's supported HTTP verbs and relevant route restrictions: + /// Paths including primary key: + /// - GET (by ID), PUT, PATCH, DELETE + /// 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() + { + OpenApiPaths pathsCollection = new(); + + 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) + { + if (entity.GetRestEnabledOrPathSettings() is bool restEnabled) + { + if (!restEnabled) + { + continue; + } + } + } + + // 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 + }; + + pathsCollection.TryAdd(entityBasePathComponent, openApiPathItem); + } + } + + return pathsCollection; + } + + /// + /// Creates OpenApiOperation definitions for entities with SourceType.Table/View + /// + /// 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) + { + Dictionary openApiPathItemOperations = new(); + + if (includePrimaryKeyPathComponent) + { + // 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 = 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 = 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 = CreateBaseOperation(description: DELETE_DESCRIPTION, tags: tags); + deleteOperation.Responses.Add(HttpStatusCode.NoContent.ToString("D"), CreateOpenApiResponse(description: nameof(HttpStatusCode.NoContent))); + openApiPathItemOperations.Add(OperationType.Delete, deleteOperation); + + return openApiPathItemOperations; + } + else + { + // Primary key(s) are not included in the URI paths of the GET (all) and POST operations. + 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); + + // 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); + 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); + + 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) + { + switch (restMethod) + { + 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; + } + } + } + else + { + configuredOperations[OperationType.Get] = true; + configuredOperations[OperationType.Post] = true; + configuredOperations[OperationType.Put] = true; + configuredOperations[OperationType.Patch] = true; + configuredOperations[OperationType.Delete] = true; + } + + return configuredOperations; + } + + /// + /// 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() + { + { + MediaTypeNames.Application.Json, + new() + { + Schema = new OpenApiSchema() + { + Reference = new OpenApiReference() + { + Type = ReferenceType.Schema, + Id = schemaReferenceId + } + } + } + } + }, + Required = requestBodyRequired + }; + + return requestBody; + } + + /// + /// 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} + /// + /// Name of the entity. + /// 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); + 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; + + if (_metadataProvider.TryGetExposedColumnName(entityName, column, out string? mappedColumnAlias) && !string.IsNullOrEmpty(mappedColumnAlias)) + { + columnNameForComponent = mappedColumnAlias; + } + + // The SourceDefinition's Columns dictionary keys represent the original (unmapped) column names. + if (sourceDefinition.Columns.TryGetValue(column, out ColumnDefinition? columnDef)) + { + OpenApiSchema parameterSchema = new() + { + Type = columnDef is not null ? TypeHelper.SystemTypeToJsonDataType(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); + } + } + + return new(pkComponents.ToString(), parameters); + } + + /// + /// Determines whether the database object has an autogenerated 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 DoesSourceContainAutogeneratedPrimaryKey(SourceDefinition sourceDefinition) + { + bool sourceObjectHasAutogeneratedPK = false; + // Create primary key path component. + 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; + break; + } + } + + return sourceObjectHasAutogeneratedPK; + } + + /// + /// Evaluates a database object's fields to determine whether a request body is required. + /// 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, bool considerPrimaryKeys) + { + bool requestBodyRequired = false; + + foreach (KeyValuePair columnMetadata in sourceDef.Columns) + { + // 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; + } + + // 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 requestBodyRequired; + } + + /// + /// 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. + private string GetEntityRestPath(string entityName) + { + string entityRestPath = entityName; + object? entityRestSettings = _runtimeConfig.Entities[entityName].GetRestEnabledOrPathSettings(); + + 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(Equals('/', entityRestPath)); + return entityRestPath; + } + + /// + /// 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, bool includeNextLink = false) + { + OpenApiResponse response = new() + { + Description = description + }; + + // No entityname means no response object schema should be included. + // the entityname references the schema of the response object. + if (!string.IsNullOrEmpty(responseObjectSchemaName)) + { + Dictionary contentDictionary = new() + { + { + MediaTypeNames.Application.Json, + CreateResponseContainer(responseObjectSchemaName, includeNextLink) + } + }; + response.Content = contentDictionary; + } + + return response; + } + + /// + /// 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, bool includeNextLink) + { + // 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 + } + }; + + if (includeNextLink) + { + OpenApiSchema nextLinkSchema = new() + { + Type = "string" + }; + responseBodyProperties.Add("nextLink", nextLinkSchema); + } + + OpenApiMediaType responsePayload = new() + { + Schema = new() + { + Properties = responseBodyProperties + } + }; + + return responsePayload; + } + + /// + /// 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}_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. + private Dictionary CreateComponentSchemas() + { + Dictionary schemas = new(); + + 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) + { + if (!restEnabled) + { + continue; + } + } + } + + SourceDefinition sourceDefinition = _metadataProvider.GetSourceDefinition(entityName); + HashSet exposedColumnNames = GetExposedColumnNames(entityName, sourceDefinition.Columns.Keys.ToList()); + 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. + schemas.Add(entityName, CreateComponentSchema(entityName, fields: exposedColumnNames)); + + if (dbObject.SourceType is not SourceType.StoredProcedure) + { + // 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) + { + // Non-Autogenerated primary key(s) should appear in the request body. + if (!sourceDefinition.Columns[primaryKeyColumn].IsAutoGenerated) + { + nonAutoGeneratedPKColumnNames.Add(primaryKeyColumn); + continue; + } + + if (_metadataProvider.TryGetExposedColumnName(entityName, backingFieldName: primaryKeyColumn, out string? exposedColumnName) + && exposedColumnName is not null) + { + exposedColumnNames.Remove(exposedColumnName); + } + } + + schemas.Add($"{entityName}_NoAutoPK", CreateComponentSchema(entityName, fields: exposedColumnNames)); + + // Create an entity's request body component schema excluding all primary keys + // 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 nonAutoGeneratedPKColumnNames) + { + if (_metadataProvider.TryGetExposedColumnName(entityName, backingFieldName: primaryKeyColumn, out string? exposedColumnName) + && exposedColumnName is not null) + { + exposedColumnNames.Remove(exposedColumnName); + } + } + + schemas.Add($"{entityName}_NoPK", CreateComponentSchema(entityName, fields: exposedColumnNames)); + } + } + + return schemas; + } + + /// + /// Creates the schema object for an entity. + /// + /// Name of the entity. + /// List of mapped (alias) field names. + /// 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, HashSet fields) + { + if (!_metadataProvider.EntityToDatabaseObject.TryGetValue(entityName, out DatabaseObject? dbObject) || dbObject is null) + { + throw new DataApiBuilderException( + message: $"{DOCUMENT_CREATION_FAILED_ERROR}: Database object metadata not found for the entity {entityName}.", + statusCode: HttpStatusCode.InternalServerError, + subStatusCode: DataApiBuilderException.SubStatusCodes.OpenApiDocumentCreationFailure); + } + + Dictionary properties = new(); + foreach (string field in fields) + { + 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 = TypeHelper.SystemTypeToJsonDataType(columnDef.SystemType).ToString().ToLower(); + } + + properties.Add(field, new OpenApiSchema() + { + Type = typeMetadata, + Format = formatMetadata + }); + } + } + + OpenApiSchema schema = new() + { + Type = "object", + Properties = properties + }; + + 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 HashSet GetExposedColumnNames(string entityName, IEnumerable unmappedColumnNames) + { + HashSet 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. + /// 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("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; + } + } +} diff --git a/src/Service/Services/OpenAPI/SwaggerEndpointMapper.cs b/src/Service/Services/OpenAPI/SwaggerEndpointMapper.cs new file mode 100644 index 0000000000..c93cef4e00 --- /dev/null +++ b/src/Service/Services/OpenAPI/SwaggerEndpointMapper.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +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 +{ + /// + /// 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? _runtimeConfigProvider; + + /// + /// Constructor to setup required services + /// + /// 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) + { + _runtimeConfigProvider = runtimeConfigProvider; + } + + /// + /// 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() + { + string configuredRestPath = _runtimeConfigProvider?.RestPath ?? GlobalSettings.REST_DEFAULT_PATH; + yield return new UrlDescriptor { Name = "DataApibuilder-OpenAPI-PREVIEW", Url = $"{configuredRestPath}/{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(); + } +} diff --git a/src/Service/Services/RestService.cs b/src/Service/Services/RestService.cs index d69bb5b8d8..8a8e0ee13d 100644 --- a/src/Service/Services/RestService.cs +++ b/src/Service/Services/RestService.cs @@ -348,27 +348,25 @@ 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}... + /// 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 /. /// - /// 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) + /// {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) { - // 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)) + string configuredRestPathBase = _runtimeConfigProvider.RestPath; + + // 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 '/'. + configuredRestPathBase = configuredRestPathBase.Substring(1); + if (!route.StartsWith(configuredRestPathBase)) { throw new DataApiBuilderException( message: $"Invalid Path for route: {route}.", @@ -376,9 +374,44 @@ private bool TryGetStoredProcedureRESTVerbs(string entityName, [NotNullWhen(true 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. - string routeAfterPath = route.Substring(restPath.Length).TrimStart('/'); + // 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 + /// 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 +419,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 c737e73e10..cb67942bb7 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; @@ -249,6 +250,7 @@ public void ConfigureServices(IServiceCollection services) }); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); AddGraphQL(services); @@ -359,6 +361,18 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, RuntimeC // 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. + // 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(app.ApplicationServices.GetService()); + }); + } + app.UseRouting(); // Adding CORS Middleware @@ -621,6 +635,20 @@ private async Task PerformOnConfigChangeAsync(IApplicationBuilder app) runtimeConfigValidator.ValidateStoredProceduresInConfig(runtimeConfig, sqlMetadataProvider!); + // Attempt to create OpenAPI document. + // 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(); + openApiDocumentor.CreateDocument(); + } + catch (DataApiBuilderException dabException) + { + _logger.LogError($"{dabException.Message}"); + } + _logger.LogInformation($"Successfully completed runtime initialization."); return true; }