269 lines
9.6 KiB
C#
269 lines
9.6 KiB
C#
/*
|
||
* dbMango
|
||
*
|
||
* Copyright 2025 Deutsche Bank AG
|
||
* SPDX-License-Identifier: Apache-2.0
|
||
*
|
||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||
* you may not use this file except in compliance with the License.
|
||
* You may obtain a copy of the License at
|
||
*
|
||
* http://www.apache.org/licenses/LICENSE-2.0
|
||
*
|
||
* Unless required by applicable law or agreed to in writing, software
|
||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
* See the License for the specific language governing permissions and
|
||
* limitations under the License.
|
||
*/
|
||
using log4net;
|
||
using Microsoft.Extensions.Options;
|
||
using Rms.Service.Bootstrap.Security;
|
||
using System.Collections.Concurrent;
|
||
using System.IO.Compression;
|
||
using System.Net;
|
||
using System.Reflection;
|
||
using System.Text;
|
||
|
||
namespace Rms.Risk.Mango.Services;
|
||
|
||
/// <summary>
|
||
/// Provides functionality for retrieving and caching documentation for MongoDB commands.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// This service fetches documentation for MongoDB commands from a remote source, caches the results,
|
||
/// and handles gzip-compressed files. It ensures that documentation is retrieved efficiently and avoids redundant
|
||
/// network calls by caching both existing and non-existing documentation.
|
||
/// </remarks>
|
||
public class DocumentationService : IDocumentationService
|
||
{
|
||
private readonly IHttpClientFactory _factory;
|
||
private static readonly ILog _log = LogManager.GetLogger(MethodBase.GetCurrentMethod()!.DeclaringType!);
|
||
|
||
private readonly DbMangoSettings _dbMangoSettings;
|
||
private readonly string _tempFolderPath;
|
||
|
||
private readonly ConcurrentDictionary<string, string?> _documentationCache = new(StringComparer.OrdinalIgnoreCase);
|
||
|
||
public DocumentationService(
|
||
IOptions<DbMangoSettings> dbMangoSettings,
|
||
IHttpClientFactory factory
|
||
)
|
||
{
|
||
_factory = factory;
|
||
_dbMangoSettings = dbMangoSettings.Value;
|
||
|
||
_tempFolderPath = Path.Combine(Path.GetTempPath(), "MongoDbDocumentation");
|
||
if (!Directory.Exists(_tempFolderPath))
|
||
{
|
||
Directory.CreateDirectory(_tempFolderPath);
|
||
}
|
||
}
|
||
|
||
public async Task<string?> TryGetHint(string commandName, CancellationToken token = default)
|
||
{
|
||
try
|
||
{
|
||
if ( string.IsNullOrWhiteSpace(_dbMangoSettings.MongoDbDocUrl))
|
||
return null;
|
||
|
||
commandName = commandName.Trim();
|
||
if ( commandName.Length < 4)
|
||
return null;
|
||
|
||
if ( _documentationCache.TryGetValue(commandName, out var documentation))
|
||
return documentation;
|
||
|
||
documentation = await LoadCommandDocumentation(commandName, token);
|
||
_log.Info( !string.IsNullOrWhiteSpace(documentation)
|
||
? $"Loaded documentation for command '{commandName}'"
|
||
: $"No documentation found for command '{commandName}'"
|
||
);
|
||
|
||
_documentationCache[commandName] = documentation; // even if empty. this prevents re-fetching of non-existing documentation
|
||
return documentation;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_log.Warn($"Failed to load documentation for command '{commandName}': {ex.Message}", ex);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
public async Task<string?> TryGetMarkdown(string commandName, CancellationToken token = default)
|
||
{
|
||
try
|
||
{
|
||
if ( string.IsNullOrWhiteSpace(_dbMangoSettings.MongoDbDocUrl))
|
||
return null;
|
||
|
||
commandName = commandName.Trim();
|
||
if ( commandName.Length < 4)
|
||
return null;
|
||
|
||
var lines = await DownloadDocumentation(commandName, token);
|
||
if ( lines.Length == 0 )
|
||
return null;
|
||
|
||
var converter = new RstToMarkdownConverter();
|
||
var markdown = converter.Convert(lines);
|
||
return markdown;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_log.Warn($"Failed to load documentation for command '{commandName}': {ex.Message}", ex);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private string GetMongoDbDocUrl(string commandName) => $"{_dbMangoSettings.MongoDbDocUrl}{commandName}.txt";
|
||
|
||
private async Task<string?> LoadCommandDocumentation(string commandName, CancellationToken token)
|
||
{
|
||
var lines = await DownloadDocumentation(commandName, token);
|
||
if ( lines.Length == 0 )
|
||
return null;
|
||
|
||
var syntaxIndex = Array.FindIndex(lines, line => line.Trim() == "Syntax");
|
||
if (syntaxIndex == -1)
|
||
{
|
||
throw new InvalidDataException("Syntax section not found in documentation.");
|
||
}
|
||
|
||
var codeBlockIndex = Array.FindIndex(lines, syntaxIndex, line => line.Trim() == ".. code-block:: javascript");
|
||
if (codeBlockIndex == -1)
|
||
{
|
||
throw new InvalidDataException("Code block section not found in documentation.");
|
||
}
|
||
|
||
var codeBlock = new List<string>();
|
||
for (var i = codeBlockIndex + 1; i < lines.Length; i++)
|
||
{
|
||
if (!lines[i].StartsWith(" ") && !String.IsNullOrWhiteSpace(lines[i]))
|
||
break;
|
||
|
||
codeBlock.Add(lines[i]);
|
||
}
|
||
|
||
return string.Join(Environment.NewLine, codeBlock);
|
||
}
|
||
|
||
private async Task<string[]> DownloadDocumentation(string commandName, CancellationToken token)
|
||
{
|
||
var mongoDbDocUrl = _dbMangoSettings.MongoDbDocUrl;
|
||
if ( string.IsNullOrWhiteSpace(mongoDbDocUrl))
|
||
return [];
|
||
|
||
if ( mongoDbDocUrl.EndsWith(".zip"))
|
||
return LoadFromZip(commandName, token);
|
||
|
||
return await LoadFromWeb(commandName, token);
|
||
}
|
||
|
||
private string[] LoadFromZip(string commandName, CancellationToken token)
|
||
{
|
||
if ( string.IsNullOrWhiteSpace(_dbMangoSettings.MongoDbDocUrl) ||
|
||
!_dbMangoSettings.MongoDbDocUrl.EndsWith(".zip"))
|
||
{
|
||
return [];
|
||
}
|
||
|
||
var zipFilePath = _dbMangoSettings.MongoDbDocUrl;
|
||
if (!File.Exists(zipFilePath))
|
||
zipFilePath = Path.Combine(AppContext.BaseDirectory, _dbMangoSettings.MongoDbDocUrl);
|
||
|
||
if (!File.Exists(zipFilePath))
|
||
return [];
|
||
|
||
using var zipArchive = ZipFile.OpenRead(zipFilePath);
|
||
var entry = zipArchive.GetEntry($"{commandName}.txt");
|
||
if (entry == null)
|
||
return [];
|
||
|
||
using var stream = entry.Open();
|
||
using var reader = new StreamReader(stream);
|
||
var content = reader.ReadToEnd();
|
||
|
||
return content.Replace("\r", "").Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||
}
|
||
|
||
private async Task<string[]> LoadFromWeb(string commandName, CancellationToken token)
|
||
{
|
||
var url = GetMongoDbDocUrl(commandName);
|
||
var filePath = Path.Combine(_tempFolderPath, $"{commandName}.txt.gz");
|
||
|
||
if (!File.Exists(filePath))
|
||
{
|
||
using var httpClient = CreateClient();
|
||
await DownloadFileFromUrlAsync( httpClient, url, filePath, token);
|
||
}
|
||
else
|
||
_log.Debug($"Documentation file already exists: {filePath}");
|
||
|
||
var lines = await ReadGzipCompressedFileAsync(filePath, token);
|
||
return lines;
|
||
}
|
||
|
||
private static async Task DownloadFileFromUrlAsync(HttpClient httpClient, string url, string destinationFilePath, CancellationToken token)
|
||
{
|
||
_log.Debug($"Downloading file from URL: {url}");
|
||
|
||
var response = await httpClient.GetAsync(url, token);
|
||
|
||
if (!response.IsSuccessStatusCode)
|
||
throw new HttpRequestException($"Failed to download file from URL: {url}. Status code: {response.StatusCode}");
|
||
|
||
token.ThrowIfCancellationRequested();
|
||
|
||
var fileContent = await response.Content.ReadAsStringAsync(token);
|
||
if (File.Exists(destinationFilePath))
|
||
return;
|
||
|
||
await WriteFileGzipCompressedAsync(destinationFilePath, fileContent, token);
|
||
}
|
||
|
||
private HttpClient CreateClient()
|
||
{
|
||
if (string.IsNullOrWhiteSpace(_dbMangoSettings.MongoDbDocProxyUrl))
|
||
return _factory.CreateClient();
|
||
|
||
var handler = CertificateHelper.ConfigureHttpsHandler("MongoDBDoc");
|
||
handler.Proxy = new WebProxy(_dbMangoSettings.MongoDbDocProxyUrl);
|
||
|
||
return new (handler);
|
||
}
|
||
|
||
|
||
private static async Task WriteFileGzipCompressedAsync(string destinationFilePath, string fileContent, CancellationToken token)
|
||
{
|
||
token.ThrowIfCancellationRequested();
|
||
|
||
await using var fileStream = new FileStream(destinationFilePath, FileMode.Create, FileAccess.Write, FileShare.None);
|
||
await using var gzipStream = new GZipStream(fileStream, CompressionLevel.Optimal);
|
||
var bytes = Encoding.UTF8.GetBytes(fileContent);
|
||
await gzipStream.WriteAsync(bytes, token);
|
||
}
|
||
private static async Task<string[]> ReadGzipCompressedFileAsync(string filePath, CancellationToken token)
|
||
{
|
||
token.ThrowIfCancellationRequested();
|
||
|
||
await using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||
await using var gzipStream = new GZipStream(fileStream, CompressionMode.Decompress);
|
||
using var streamReader = new StreamReader(gzipStream);
|
||
|
||
var lines = new List<string>();
|
||
while (!streamReader.EndOfStream)
|
||
{
|
||
token.ThrowIfCancellationRequested();
|
||
var line = await streamReader.ReadLineAsync(token);
|
||
if (line != null)
|
||
{
|
||
lines.Add(line);
|
||
}
|
||
}
|
||
|
||
return lines.ToArray();
|
||
}
|
||
|
||
} |