Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Hot Reload the OpenApi Document #1870

Draft
wants to merge 28 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
6a34745
draft
aaronburtle Sep 18, 2023
07ae692
call into hotreload within configprovider on call back
aaronburtle Sep 19, 2023
1d4876a
startup
aaronburtle Sep 19, 2023
7aee7cf
some strtup changes
aaronburtle Sep 29, 2023
af2caf5
remove Ioptions, correct file location for watcher
aaronburtle Oct 3, 2023
ada7412
Merge branch 'main' into dev/aaronburtle/HotReloadRuntime
aaronburtle Oct 3, 2023
020a79a
refactor, change how we read file, instantiate watcher
aaronburtle Oct 5, 2023
2b2924e
format
aaronburtle Oct 5, 2023
70585d3
new design for instantiating the file watcher
aaronburtle Oct 12, 2023
4163697
unit test'
aaronburtle Oct 12, 2023
2e605b5
small refactor, add comments
aaronburtle Oct 17, 2023
d03a904
added missing field to file watcher
aaronburtle Oct 18, 2023
9213bf4
refactor tests and use mock file system
aaronburtle Oct 20, 2023
fa3f266
have to use real file system :(
aaronburtle Oct 20, 2023
13d353f
remove import for mock file system
aaronburtle Oct 20, 2023
fab3117
Merge branch 'main' into dev/aaronburtle/HotReloadRuntime
aaronburtle Oct 20, 2023
e684f86
fix unrelated test
aaronburtle Oct 20, 2023
61cb8cd
hold over from other test strategy
aaronburtle Oct 20, 2023
f736f56
should not have reverted that
aaronburtle Oct 20, 2023
7ce8e25
remove some comments
aaronburtle Oct 20, 2023
1f44625
clarify comment
aaronburtle Oct 20, 2023
5c77852
add function summary
aaronburtle Nov 7, 2023
5c3214c
add integration tests, use constant for default source name
aaronburtle Nov 8, 2023
622dc03
defensive null check
aaronburtle Nov 8, 2023
b40bb83
Merge branch 'main' into dev/aaronburtle/HotReloadOpenApiDoc
aaronburtle Nov 8, 2023
1f1c043
removed event handler to try using DI and call create doc
aaronburtle Nov 8, 2023
ebda1cc
trying for DI
aaronburtle Nov 8, 2023
3390cbe
Use DI to call createDocument
aaronburtle Nov 8, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/Config/FileSystemRuntimeConfigLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public class FileSystemRuntimeConfigLoader : RuntimeConfigLoader
// or user provided config file which could be a relative file path, absolute file path or simply the file name assumed to be in current directory.
private string _baseConfigFilePath;

private readonly IFileSystem _fileSystem;
public readonly IFileSystem _fileSystem;

public const string CONFIGFILE_NAME = "dab-config";
public const string CONFIG_EXTENSION = ".json";
Expand Down
2 changes: 1 addition & 1 deletion src/Config/ObjectModel/RuntimeConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ public RuntimeConfig(string? Schema, DataSource DataSource, RuntimeEntities Enti
this.DataSource = DataSource;
this.Runtime = Runtime;
this.Entities = Entities;
_defaultDataSourceName = Guid.NewGuid().ToString();
_defaultDataSourceName = "DEFAULT_DATASOURCE_NAME";

// we will set them up with default values
_dataSourceNameToDataSource = new Dictionary<string, DataSource>
Expand Down
69 changes: 69 additions & 0 deletions src/Core/Configurations/ConfigFileWatcher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Copyright (c) Microsoft Corporation.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this class being shown as newly added, this was already a part of your last PR that got merged.

// Licensed under the MIT License.

using System.IO.Abstractions;
using Azure.DataApiBuilder.Config;
using Azure.DataApiBuilder.Config.ObjectModel;
using Azure.DataApiBuilder.Core.Services;
using Path = System.IO.Path;

namespace Azure.DataApiBuilder.Core.Configurations;

public class ConfigFileWatcher
{
private FileSystemWatcher? _fileWatcher;
RuntimeConfigProvider? _configProvider;
IOpenApiDocumentor? _documentor;

public ConfigFileWatcher(RuntimeConfigProvider configProvider, IOpenApiDocumentor documentor)
{
FileSystemRuntimeConfigLoader loader = (FileSystemRuntimeConfigLoader)configProvider.ConfigLoader;
string configFileName = loader.ConfigFilePath;
IFileSystem fileSystem = (IFileSystem)loader._fileSystem;
string? currentDirectoryPath = fileSystem.Directory.GetCurrentDirectory();
string configFilePath = Path.Combine(currentDirectoryPath!, configFileName);
string path = Path.GetDirectoryName(configFilePath)!;
_documentor = documentor;
_fileWatcher = new FileSystemWatcher(Path.GetDirectoryName(configFilePath)!)
{
Filter = Path.GetFileName(configFilePath),
EnableRaisingEvents = true
};

_configProvider = configProvider;
_fileWatcher.Changed += OnConfigFileChange;
}

public ConfigFileWatcher() { }

/// <summary>
/// When a change is detected in the Config file being watched this trigger
/// function is called and handles the hot reload logic when appropriate,
/// ie: in a local development scenario.
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void OnConfigFileChange(object sender, FileSystemEventArgs e)
{
try
{
if (_configProvider is null)
{
throw new ArgumentNullException("_configProvider can not be null.");
}

if (!_configProvider!.IsLateConfigured && _configProvider!.GetConfig().Runtime!.Host!.Mode is HostMode.Development)
{
_configProvider!.HotReloadConfig();
_documentor!.CreateDocument();
}
}
catch (Exception ex)
{
// Need to remove the dependency configuring authentication has on the RuntimeConfigProvider
// before we can have an ILogger here.
Console.WriteLine("Unable to Hot Reload configuration file due to " + ex.Message);
}
}
}

37 changes: 37 additions & 0 deletions src/Core/Configurations/RuntimeConfigProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using Azure.DataApiBuilder.Config.Converters;
using Azure.DataApiBuilder.Config.NamingPolicies;
using Azure.DataApiBuilder.Config.ObjectModel;
using Azure.DataApiBuilder.Core.Services;
using Azure.DataApiBuilder.Service.Exceptions;

namespace Azure.DataApiBuilder.Core.Configurations;
Expand Down Expand Up @@ -44,6 +45,10 @@ public class RuntimeConfigProvider

public RuntimeConfigLoader ConfigLoader { get; private set; }

private ConfigFileWatcher? ConfigFileWatcher { get; set; }

public IOpenApiDocumentor? Documentor { get; set; }

private RuntimeConfig? _runtimeConfig;

public RuntimeConfigProvider(RuntimeConfigLoader runtimeConfigLoader)
Expand Down Expand Up @@ -79,9 +84,31 @@ public RuntimeConfig GetConfig()
subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization);
}

CheckForAndSetupConfigFileWatcher();
return _runtimeConfig;
}

/// <summary>
/// Checks if we are in a local development scenario, and if so, instantiate
/// the config file watcher with a reference to this RuntimeConfigProvider. Otherwise
/// we will call the no argument constructor, which means no file will actually be
/// watched.
/// </summary>
private void CheckForAndSetupConfigFileWatcher()
{
if (ConfigFileWatcher is null)
{
if (!IsLateConfigured && _runtimeConfig!.Runtime!.Host!.Mode is HostMode.Development)
{
ConfigFileWatcher = new(this, Documentor!);
}
else
{
ConfigFileWatcher = new();
}
}
}

/// <summary>
/// Attempt to acquire runtime configuration metadata.
/// </summary>
Expand All @@ -94,6 +121,7 @@ public bool TryGetConfig([NotNullWhen(true)] out RuntimeConfig? runtimeConfig)
if (ConfigLoader.TryLoadKnownConfig(out RuntimeConfig? config, replaceEnvVar: true))
{
_runtimeConfig = config;
CheckForAndSetupConfigFileWatcher();
}
}

Expand All @@ -113,6 +141,15 @@ public bool TryGetLoadedConfig([NotNullWhen(true)] out RuntimeConfig? runtimeCon
return _runtimeConfig is not null;
}

/// <summary>
/// Hot Reloads the runtime config when the file watcher
/// is active and detects a change to the underlying config file.
/// </summary>
public void HotReloadConfig()
{
ConfigLoader.TryLoadKnownConfig(out _runtimeConfig);
}

/// <summary>
/// Initialize the runtime configuration provider with the specified configurations.
/// This initialization method is used when the configuration is sent to the ConfigurationController
Expand Down
27 changes: 16 additions & 11 deletions src/Core/Services/OpenAPI/OpenApiDocumentor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ namespace Azure.DataApiBuilder.Core.Services
public class OpenApiDocumentor : IOpenApiDocumentor
{
private readonly IMetadataProviderFactory _metadataProviderFactory;
private readonly RuntimeConfig _runtimeConfig;
private readonly RuntimeConfigProvider _configProvider;
private OpenApiResponses _defaultOpenApiResponses;
private OpenApiDocument? _openApiDocument;

Expand Down Expand Up @@ -58,7 +58,7 @@ public class OpenApiDocumentor : IOpenApiDocumentor
public OpenApiDocumentor(IMetadataProviderFactory metadataProviderFactory, RuntimeConfigProvider runtimeConfigProvider)
{
_metadataProviderFactory = metadataProviderFactory;
_runtimeConfig = runtimeConfigProvider.GetConfig();
_configProvider = runtimeConfigProvider;
_defaultOpenApiResponses = CreateDefaultOpenApiResponses();
}

Expand Down Expand Up @@ -96,6 +96,7 @@ public bool TryGetDocument([NotNullWhen(true)] out string? document)
/// <seealso cref="https://github.com/microsoft/OpenAPI.NET/blob/1.6.3/src/Microsoft.OpenApi/OpenApiSpecVersion.cs"/>
public void CreateDocument()
{
RuntimeConfig config = _configProvider.GetConfig();
if (_openApiDocument is not null)
{
throw new DataApiBuilderException(
Expand All @@ -104,7 +105,7 @@ public void CreateDocument()
subStatusCode: DataApiBuilderException.SubStatusCodes.OpenApiDocumentAlreadyExists);
}

if (!_runtimeConfig.IsRestEnabled)
if (!config.IsRestEnabled)
{
throw new DataApiBuilderException(
message: DOCUMENT_CREATION_UNSUPPORTED_ERROR,
Expand All @@ -114,8 +115,8 @@ public void CreateDocument()

try
{
string restEndpointPath = _runtimeConfig.RestPath;
string? runtimeBaseRoute = _runtimeConfig.Runtime?.BaseRoute;
string restEndpointPath = config.RestPath;
string? runtimeBaseRoute = config.Runtime?.BaseRoute;
string url = string.IsNullOrEmpty(runtimeBaseRoute) ? restEndpointPath : runtimeBaseRoute + "/" + restEndpointPath;
OpenApiComponents components = new()
{
Expand Down Expand Up @@ -166,8 +167,9 @@ public void CreateDocument()
private OpenApiPaths BuildPaths()
{
OpenApiPaths pathsCollection = new();
RuntimeConfig config = _configProvider.GetConfig();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we pass the config here from CreateDocument() ? is there a possibility the config that is sourced in CreateDocument() is different than the config sourced in BuildPaths()?


string defaultDataSourceName = _runtimeConfig.GetDefaultDataSourceName();
string defaultDataSourceName = config.GetDefaultDataSourceName();
ISqlMetadataProvider metadataProvider = _metadataProviderFactory.GetMetadataProvider(defaultDataSourceName);
foreach (KeyValuePair<string, DatabaseObject> entityDbMetadataMap in metadataProvider.EntityToDatabaseObject)
{
Expand All @@ -180,7 +182,7 @@ private OpenApiPaths BuildPaths()

// 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 (config.Entities.TryGetValue(entityName, out Entity? entity) && entity is not null)
{
if (!entity.Rest.Enabled)
{
Expand Down Expand Up @@ -453,7 +455,8 @@ private OpenApiOperation CreateBaseOperation(string description, List<OpenApiTag

if (dbObject.SourceType == EntitySourceType.StoredProcedure)
{
Entity entity = _runtimeConfig.Entities[entityName];
RuntimeConfig config = _configProvider.GetConfig();
Entity entity = config.Entities[entityName];

List<SupportedHttpVerb>? spRestMethods;
if (entity.Rest.Methods is not null)
Expand Down Expand Up @@ -683,8 +686,9 @@ private static bool IsRequestBodyRequired(SourceDefinition sourceDef, bool consi
/// <returns>Returns the REST path name for the provided entity with no starting slash: {entityName} or {entityRestPath}.</returns>
private string GetEntityRestPath(string entityName)
{
RuntimeConfig config = _configProvider.GetConfig();
string entityRestPath = entityName;
EntityRestOptions entityRestSettings = _runtimeConfig.Entities[entityName].Rest;
EntityRestOptions entityRestSettings = config.Entities[entityName].Rest;

if (!string.IsNullOrEmpty(entityRestSettings.Path))
{
Expand Down Expand Up @@ -798,9 +802,10 @@ private static OpenApiMediaType CreateResponseContainer(string responseObjectSch
private Dictionary<string, OpenApiSchema> CreateComponentSchemas()
{
Dictionary<string, OpenApiSchema> schemas = new();
RuntimeConfig config = _configProvider.GetConfig();

// for rest scenario we need the default datasource name.
string defaultDataSourceName = _runtimeConfig.GetDefaultDataSourceName();
string defaultDataSourceName = config.GetDefaultDataSourceName();
ISqlMetadataProvider metadataProvider = _metadataProviderFactory.GetMetadataProvider(defaultDataSourceName);

foreach (KeyValuePair<string, DatabaseObject> entityDbMetadataMap in metadataProvider.EntityToDatabaseObject)
Expand All @@ -810,7 +815,7 @@ private static OpenApiMediaType CreateResponseContainer(string responseObjectSch
string entityName = entityDbMetadataMap.Key;
DatabaseObject dbObject = entityDbMetadataMap.Value;

if (_runtimeConfig.Entities.TryGetValue(entityName, out Entity? entity) && entity is not null)
if (config.Entities.TryGetValue(entityName, out Entity? entity) && entity is not null)
{
if (!entity.Rest.Enabled)
{
Expand Down
61 changes: 61 additions & 0 deletions src/Service.Tests/Configuration/ConfigurationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1337,6 +1337,67 @@ public async Task TestRuntimeBaseRouteInNextLinkForPaginatedRestResponse()
}
}

/// <summary>
/// Test to validate a hot reload scenario. Make a rest request to
/// an endpoint at /api/MappedBookmarks, then hotreload the rest path
/// to /hotreloaded/MappedBookmarks and validate the request still
/// returns OK status code.
/// </summary>
[TestMethod]
[TestCategory(TestCategory.MSSQL)]
public async Task TestRuntimeBaseRouteInAfterHotReload()
{
// use the mssql test config file to start test server
TestHelper.SetupDatabaseEnvironment(TestCategory.MSSQL);
// setup loader to handle file operations
FileSystemRuntimeConfigLoader loader = TestHelper.GetRuntimeConfigLoader();
string configFileName = loader.ConfigFilePath;
IFileSystem fileSystem = (IFileSystem)loader._fileSystem;
string currentDirectoryPath = fileSystem.Directory.GetCurrentDirectory();
string configFilePath = System.IO.Path.Combine(currentDirectoryPath!, configFileName);
string[] args = new[]
{
$"--ConfigFileName={configFileName}"
};

using (TestServer server = new(Program.CreateWebHostBuilder(args)))
using (HttpClient client = server.CreateClient())
{
string requestPath = "/api/MappedBookmarks";
HttpMethod httpMethod = SqlTestHelper.ConvertRestMethodToHttpMethod(SupportedHttpVerb.Get);
HttpRequestMessage request = new(httpMethod, requestPath);
HttpResponseMessage response = await client.SendAsync(request);
Assert.IsTrue(response.StatusCode is HttpStatusCode.OK);
// this will overwrite the rest path in the mssql test config file
requestPath = "/hotreloaded";
loader.TryLoadKnownConfig(out RuntimeConfig config);
RuntimeConfig hotReloadConfig =
config
with
{
Runtime = config.Runtime
with
{
Rest = config.Runtime.Rest
with
{
Path = requestPath
}
}
};

// we write to the config which is being monitored and sleep to allow hot reload to occur
fileSystem.File.WriteAllText(configFilePath, hotReloadConfig.ToJson());
Thread.Sleep(1000);
requestPath += "/MappedBookmarks";
request = new(httpMethod, requestPath);
response = await client.SendAsync(request);
Assert.IsTrue(response.StatusCode is HttpStatusCode.OK);
// overwrites the changes we made when hot reloading
fileSystem.File.WriteAllText(configFilePath, config.ToJson());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is the use of this last line in testing?

}
}

/// <summary>
/// Tests that the when Rest or GraphQL is disabled Globally,
/// any requests made will get a 404 response.
Expand Down