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;
}