Skip to content

Commit ac9ae70

Browse files
Validates plugins configs against schema's. Closes #1003 (#1005)
1 parent 5dc7ddd commit ac9ae70

23 files changed

+499
-580
lines changed
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using Microsoft.Extensions.Logging;
6+
using System.Text.Json;
7+
8+
namespace DevProxy.Abstractions;
9+
10+
public abstract class BaseLoader(ILogger logger, bool validateSchemas) : IDisposable
11+
{
12+
private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger));
13+
private readonly bool _validateSchemas = validateSchemas;
14+
private FileSystemWatcher? _watcher;
15+
protected abstract string FilePath { get; }
16+
17+
protected abstract void LoadData(string fileContents);
18+
19+
private async Task<bool> ValidateFileContents(string fileContents)
20+
{
21+
using var document = JsonDocument.Parse(fileContents);
22+
var root = document.RootElement;
23+
24+
if (!root.TryGetProperty("$schema", out var schemaUrl))
25+
{
26+
_logger.LogDebug("Schema reference not found in file {File}. Skipping schema validation", FilePath);
27+
return true;
28+
}
29+
30+
var (IsValid, ValidationErrors) = await ProxyUtils.ValidateJson(fileContents, schemaUrl.GetString(), _logger);
31+
if (!IsValid)
32+
{
33+
_logger.LogError("Schema validation failed for {File} with the following errors: {Errors}", FilePath, string.Join(", ", ValidationErrors));
34+
}
35+
36+
return IsValid;
37+
}
38+
39+
private void LoadFileContents()
40+
{
41+
if (!File.Exists(FilePath))
42+
{
43+
_logger.LogWarning("File {File} not found. No data will be loaded", FilePath);
44+
return;
45+
}
46+
47+
try
48+
{
49+
using var stream = new FileStream(FilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
50+
using var reader = new StreamReader(stream);
51+
var responsesString = reader.ReadToEnd();
52+
53+
if (!_validateSchemas || ValidateFileContents(responsesString).Result)
54+
{
55+
LoadData(responsesString);
56+
}
57+
}
58+
catch (Exception ex)
59+
{
60+
_logger.LogError(ex, "An error has occurred while reading {File}:", FilePath);
61+
}
62+
}
63+
64+
private void File_Changed(object sender, FileSystemEventArgs e)
65+
{
66+
LoadFileContents();
67+
}
68+
69+
public void InitFileWatcher()
70+
{
71+
if (_watcher is not null)
72+
{
73+
return;
74+
}
75+
76+
string path = Path.GetDirectoryName(FilePath) ?? throw new InvalidOperationException($"{FilePath} is an invalid path");
77+
if (!File.Exists(FilePath))
78+
{
79+
_logger.LogWarning("File {File} not found. No data will be provided", FilePath);
80+
return;
81+
}
82+
83+
_watcher = new FileSystemWatcher(Path.GetFullPath(path))
84+
{
85+
NotifyFilter = NotifyFilters.CreationTime
86+
| NotifyFilters.FileName
87+
| NotifyFilters.LastWrite
88+
| NotifyFilters.Size,
89+
Filter = Path.GetFileName(FilePath)
90+
};
91+
_watcher.Changed += File_Changed;
92+
_watcher.Created += File_Changed;
93+
_watcher.Deleted += File_Changed;
94+
_watcher.Renamed += File_Changed;
95+
_watcher.EnableRaisingEvents = true;
96+
97+
LoadFileContents();
98+
}
99+
100+
public void Dispose()
101+
{
102+
_watcher?.Dispose();
103+
GC.SuppressFinalize(this);
104+
}
105+
}
Lines changed: 93 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,93 @@
1-
// Licensed to the .NET Foundation under one or more agreements.
2-
// The .NET Foundation licenses this file to you under the MIT license.
3-
// See the LICENSE file in the project root for more information.
4-
5-
using System.CommandLine;
6-
using Microsoft.Extensions.Configuration;
7-
using Microsoft.Extensions.Logging;
8-
9-
namespace DevProxy.Abstractions;
10-
11-
public abstract class BaseProxyPlugin : IProxyPlugin
12-
{
13-
protected ISet<UrlToWatch> UrlsToWatch { get; }
14-
protected ILogger Logger { get; }
15-
protected IConfigurationSection? ConfigSection { get; }
16-
protected IPluginEvents PluginEvents { get; }
17-
protected IProxyContext Context { get; }
18-
19-
public virtual string Name => throw new NotImplementedException();
20-
21-
public virtual Option[] GetOptions() => [];
22-
public virtual Command[] GetCommands() => [];
23-
24-
public BaseProxyPlugin(IPluginEvents pluginEvents,
25-
IProxyContext context,
26-
ILogger logger,
27-
ISet<UrlToWatch> urlsToWatch,
28-
IConfigurationSection? configSection = null)
29-
{
30-
ArgumentNullException.ThrowIfNull(pluginEvents);
31-
ArgumentNullException.ThrowIfNull(context);
32-
ArgumentNullException.ThrowIfNull(logger);
33-
34-
if (urlsToWatch is null || !urlsToWatch.Any())
35-
{
36-
throw new ArgumentException($"{nameof(urlsToWatch)} cannot be null or empty", nameof(urlsToWatch));
37-
}
38-
39-
UrlsToWatch = urlsToWatch;
40-
Context = context;
41-
Logger = logger;
42-
ConfigSection = configSection;
43-
PluginEvents = pluginEvents;
44-
}
45-
46-
public virtual Task RegisterAsync()
47-
{
48-
return Task.CompletedTask;
49-
}
50-
}
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using System.CommandLine;
6+
using System.Globalization;
7+
using System.Text.Json;
8+
using Microsoft.Extensions.Configuration;
9+
using Microsoft.Extensions.Logging;
10+
11+
namespace DevProxy.Abstractions;
12+
13+
public abstract class BaseProxyPlugin : IProxyPlugin
14+
{
15+
protected ISet<UrlToWatch> UrlsToWatch { get; }
16+
protected ILogger Logger { get; }
17+
protected IConfigurationSection? ConfigSection { get; }
18+
protected IPluginEvents PluginEvents { get; }
19+
protected IProxyContext Context { get; }
20+
21+
public virtual string Name => throw new NotImplementedException();
22+
23+
public virtual Option[] GetOptions() => [];
24+
public virtual Command[] GetCommands() => [];
25+
26+
public BaseProxyPlugin(IPluginEvents pluginEvents,
27+
IProxyContext context,
28+
ILogger logger,
29+
ISet<UrlToWatch> urlsToWatch,
30+
IConfigurationSection? configSection = null)
31+
{
32+
ArgumentNullException.ThrowIfNull(pluginEvents);
33+
ArgumentNullException.ThrowIfNull(context);
34+
ArgumentNullException.ThrowIfNull(logger);
35+
36+
if (urlsToWatch is null || !urlsToWatch.Any())
37+
{
38+
throw new ArgumentException($"{nameof(urlsToWatch)} cannot be null or empty", nameof(urlsToWatch));
39+
}
40+
41+
UrlsToWatch = urlsToWatch;
42+
Context = context;
43+
Logger = logger;
44+
ConfigSection = configSection;
45+
PluginEvents = pluginEvents;
46+
}
47+
48+
public virtual async Task RegisterAsync()
49+
{
50+
var (IsValid, ValidationErrors) = await ValidatePluginConfig();
51+
if (!IsValid)
52+
{
53+
Logger.LogError("Plugin configuration validation failed with the following errors: {Errors}", string.Join(", ", ValidationErrors));
54+
}
55+
}
56+
57+
protected async Task<(bool IsValid, IEnumerable<string> ValidationErrors)> ValidatePluginConfig()
58+
{
59+
if (!Context.Configuration.ValidateSchemas || ConfigSection is null)
60+
{
61+
Logger.LogDebug("Schema validation is disabled or no configuration section specified");
62+
return (true, []);
63+
}
64+
65+
try
66+
{
67+
var schemaUrl = ConfigSection.GetValue<string>("$schema");
68+
if (string.IsNullOrWhiteSpace(schemaUrl))
69+
{
70+
Logger.LogDebug("No schema URL found in configuration file");
71+
return (true, []);
72+
}
73+
74+
var configSectionName = ConfigSection.Key;
75+
var configFile = await File.ReadAllTextAsync(Context.Configuration.ConfigFile);
76+
77+
using var document = JsonDocument.Parse(configFile);
78+
var root = document.RootElement;
79+
80+
if (!root.TryGetProperty(configSectionName, out var configSection))
81+
{
82+
Logger.LogError("Configuration section {SectionName} not found in configuration file", configSectionName);
83+
return (false, [string.Format(CultureInfo.InvariantCulture, "Configuration section {0} not found in configuration file", configSectionName)]);
84+
}
85+
86+
return await ProxyUtils.ValidateJson(configSection.GetRawText(), schemaUrl, Logger);
87+
}
88+
catch (Exception ex)
89+
{
90+
return (false, [ex.Message]);
91+
}
92+
}
93+
}

dev-proxy-abstractions/IProxyConfiguration.cs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,17 @@ public interface IProxyConfiguration
1010
{
1111
int ApiPort { get; }
1212
bool AsSystemProxy { get; }
13-
string? IPAddress { get; }
1413
string ConfigFile { get; }
15-
bool InstallCert { get; }
1614
MockRequestHeader[]? FilterByHeaders { get; }
15+
bool InstallCert { get; }
16+
string? IPAddress { get; }
1717
LogLevel LogLevel { get; }
1818
bool NoFirstRun { get; }
1919
int Port { get; }
2020
bool Record { get; }
21-
IEnumerable<int> WatchPids { get; }
22-
IEnumerable<string> WatchProcessNames { get; }
2321
bool ShowTimestamps { get; }
2422
long? TimeoutSeconds { get; }
23+
bool ValidateSchemas { get; }
24+
IEnumerable<int> WatchPids { get; }
25+
IEnumerable<string> WatchProcessNames { get; }
2526
}

dev-proxy-abstractions/ProxyUtils.cs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33
// See the LICENSE file in the project root for more information.
44

5+
using Microsoft.Extensions.Logging;
6+
using Newtonsoft.Json.Linq;
7+
using Newtonsoft.Json.Schema;
58
using System.Reflection;
69
using System.Text.Json;
710
using System.Text.Json.Serialization;
@@ -455,4 +458,42 @@ private static string GetCommonPrefix(List<string> paths)
455458
var lastSlashIndex = prefix.LastIndexOf('/');
456459
return lastSlashIndex >= 0 ? prefix[..(lastSlashIndex + 1)] : prefix;
457460
}
461+
462+
public static async Task<(bool IsValid, IEnumerable<string> ValidationErrors)> ValidateJson(string? json, string? schemaUrl, ILogger logger)
463+
{
464+
try
465+
{
466+
logger.LogDebug("Validating JSON against schema {SchemaUrl}", schemaUrl);
467+
468+
if (string.IsNullOrEmpty(json))
469+
{
470+
logger.LogDebug("JSON is empty, skipping validation");
471+
return (true, []);
472+
}
473+
if (string.IsNullOrEmpty(schemaUrl))
474+
{
475+
logger.LogDebug("Schema URL is empty, skipping validation");
476+
return (true, []);
477+
}
478+
479+
logger.LogDebug("Downloading schema from {SchemaUrl}", schemaUrl);
480+
using var client = new HttpClient();
481+
var schemaContents = await client.GetStringAsync(schemaUrl);
482+
483+
logger.LogDebug("Parsing schema");
484+
var schema = JSchema.Parse(schemaContents);
485+
logger.LogDebug("Parsing JSON");
486+
var token = JToken.Parse(json);
487+
488+
logger.LogDebug("Validating JSON");
489+
bool isValid = token.IsValid(schema, out IList<string> errorMessages);
490+
491+
return (isValid, errorMessages);
492+
}
493+
catch (Exception ex)
494+
{
495+
logger.LogDebug(ex, "Error validating JSON");
496+
return (false, [ex.Message]);
497+
}
498+
}
458499
}

dev-proxy-abstractions/dev-proxy-abstractions.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.2" />
1818
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.2" />
1919
<PackageReference Include="Microsoft.OpenApi.Readers" Version="1.6.23" />
20+
<PackageReference Include="Newtonsoft.Json.Schema" Version="4.0.1" />
2021
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
2122
<PackageReference Include="Unobtanium.Web.Proxy" Version="0.1.5" />
2223
</ItemGroup>

dev-proxy-abstractions/packages.lock.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,15 @@
6868
"SharpYaml": "2.1.1"
6969
}
7070
},
71+
"Newtonsoft.Json.Schema": {
72+
"type": "Direct",
73+
"requested": "[4.0.1, )",
74+
"resolved": "4.0.1",
75+
"contentHash": "rbHUKp5WTIbqmLEeJ21nTTDGcfR0LA7bVMzm0bYc3yx6NFKiCIHzzvYbwA4Sqgs7+wNldc5nBlkbithWj8IZig==",
76+
"dependencies": {
77+
"Newtonsoft.Json": "13.0.3"
78+
}
79+
},
7180
"System.CommandLine": {
7281
"type": "Direct",
7382
"requested": "[2.0.0-beta4.22272.1, )",
@@ -255,6 +264,11 @@
255264
"resolved": "1.6.23",
256265
"contentHash": "tZ1I0KXnn98CWuV8cpI247A17jaY+ILS9vvF7yhI0uPPEqF4P1d7BWL5Uwtel10w9NucllHB3nTkfYTAcHAh8g=="
257266
},
267+
"Newtonsoft.Json": {
268+
"type": "Transitive",
269+
"resolved": "13.0.3",
270+
"contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ=="
271+
},
258272
"SharpYaml": {
259273
"type": "Transitive",
260274
"resolved": "2.1.1",

0 commit comments

Comments
 (0)