|
| 1 | +# Support a "plug-in" model to provide extensibility |
| 2 | + |
| 3 | +To allow developers to provide unique handling behaviors for different APIs the Microsoft Graph Developer Proxy will have a plug-in model provide an extension point for developer. This will provide separation of concerns between the software components intercepting network request and those providing behaviors. |
| 4 | +To enable developers building plugins an abstractions library will be distributed on NuGet. |
| 5 | + |
| 6 | +## History |
| 7 | + |
| 8 | +| Version | Date | Comments | Author | |
| 9 | +| ------- | ---- | -------- | ------ | |
| 10 | +| 1.0 | 2022-12-19 | Initial specifications | @gavinbarron | |
| 11 | + |
| 12 | +## Plug-in system |
| 13 | + |
| 14 | +The plugin system will leverage reflection to load the custom logic at runtime via the supplied configuration. |
| 15 | +The plugin system will leverage `AssemblyLoadContext` from `System.Runtime.Loader` to allow for isolated loading of plugins and their dependencies. The approach will be based upon https://learn.microsoft.com/en-us/dotnet/core/tutorials/creating-app-with-plugin-support |
| 16 | + |
| 17 | +Should a the proxy be unable to load a plugin based upon the supplied configuration the proxy will exit with an error informing the user that the plugin was not able to be found. |
| 18 | + |
| 19 | +Plugins will utilize event listeners to provide a decoupled and late bound integration between the core proxy logic and the logic that the plugins provide. |
| 20 | + |
| 21 | +## Configuration file |
| 22 | + |
| 23 | +The existing `appsettings.json` file will be used to provide configuration. Both for determining which plugins are loaded into the proxy and providing configuration for each of the loaded plugins. The set of plugins to be loaded will be specified as an array of objects at the root of the configuration file in the `plugins` property. Each plugin must supply a `pluginPath` and `name` and may optionally provide `configSection` and `urlsToWatch` in the plugins array. |
| 24 | +- `pluginPath` - required, a string which is the relative path to the assembly containing the class definition for the plugin which will be loaded using reflection. |
| 25 | +- `name` - required, a string identifier for the plugin class, allows developer to ship multiple plugins in a single assembly. This should correspond to the value returned by the `Name` property defined on the plugin class. Plugin names must be unique per-assembly. |
| 26 | +- `configSection` - optional, a string for which there must be a matching object property at the root of the configuration file. If a plugin supplies a `configSection` and no corresponding property exists in the configuration file an error will be thrown during start up of the proxy. |
| 27 | +- `urlsToWatch` - optional, an array of strings defining the urls for which this plugin will watch instead of the `urlsToWatch` at the root of the config file, this behavior is defined in the [Multi URL support spec](./multi-url-support.md). |
| 28 | +- `disabled` - optional boolean when true the proxy will not attempt to load this plugin. |
| 29 | + |
| 30 | +```json |
| 31 | + "plugins": [ |
| 32 | + { |
| 33 | + "configSection": "randomErrors", |
| 34 | + "pluginPath": "RandomErrorPlugin\\msgraph-proxy-handler.dll", |
| 35 | + "name": "RandomErrorPlugin", |
| 36 | + "urlsToWatch": [ |
| 37 | + "https://graph.microsoft.com/v1.0/*", |
| 38 | + "https://graph.microsoft.com/beta/*" |
| 39 | + ], |
| 40 | + "disabled": false |
| 41 | + } |
| 42 | + ], |
| 43 | + "randomErrors": { |
| 44 | + "rate": 50, |
| 45 | + "allowedErrors": [ 429, 500, 502, 503, 504, 507 ] |
| 46 | + } |
| 47 | +``` |
| 48 | + |
| 49 | +Plugins will be executed in the order that they are listed in the plugins configuration array. At least one plugin must be present in the configuration, otherwise the proxy will exit with an error informing the user that their configuration is invalid and contains no plugins. |
| 50 | + |
| 51 | +## Plugins |
| 52 | + |
| 53 | +Plugin classes must implement the `IProxyPlugin` interface and provide a parameterless constructor. |
| 54 | + |
| 55 | +```cs |
| 56 | +public interface IProxyPlugin { |
| 57 | + string Name { get; } |
| 58 | + void Register(IPluginEvents pluginEvents, |
| 59 | + IProxyContext context, |
| 60 | + ISet<Regex> urlsToWatch, |
| 61 | + IConfigurationSection? configSection); |
| 62 | +} |
| 63 | + |
| 64 | +public interface IPluginEvents { |
| 65 | + event EventHandler<InitArgs> Init; |
| 66 | + event EventHandler<OptionsLoadedArgs> OptionsLoaded; |
| 67 | + event EventHandler<ProxyRequestArgs> Request; |
| 68 | + event EventHandler<ProxyResponseArgs> Response; |
| 69 | +} |
| 70 | + |
| 71 | +public interface IPluginContext { |
| 72 | + ILogger Logger { get; } |
| 73 | +} |
| 74 | +``` |
| 75 | + |
| 76 | +### Registration |
| 77 | + |
| 78 | +The `Register` method of an `IProxyPlugin` will be called after an instance is created and will provide an `ISet<Regex>` to define the urls the plugin wll watch, either using the default or a plugin specific set based on the supplied configuration, an `IProxyContext` to supply utility objects from the proxy, and an `IConfigurationSection` if one was loaded. |
| 79 | + |
| 80 | +> It is strongly recommended that plugin implementers provide a default configuration in their code. |
| 81 | +
|
| 82 | +Plugin classes should store the supplied `IProxyContext` for use when events are fired. At a minimum this context will supply the `ILogger` which plugins will use to provide output messages. The contents of the context are likely to evolve as new requirements for plugin emerge. |
| 83 | + |
| 84 | +The details of the ILogger interface and implementation are out of the scope of this spec, except to say that they will provide plugins with a standardized mechanism for logging messages using preset formatting and categorization. |
| 85 | + |
| 86 | +Plugin classes may register a handler for any events provided in the `IPluginEvents` interface. Plugins registering a handler for the `Init` event should also register a handler for the `OptionsLoaded` event to consume the supplied options. |
| 87 | + |
| 88 | +A set of static utility methods will be provided via the `ProxyUtils` class. A proposed initial implementation of the `ProxyUtils` class is provided here: |
| 89 | + |
| 90 | +```cs |
| 91 | +public static class ProxyUtils { |
| 92 | + |
| 93 | + public static bool IsSdkRequest(Request request) { |
| 94 | + return request.Headers.HeaderExists("SdkVersion"); |
| 95 | + } |
| 96 | + |
| 97 | + public static bool IsGraphRequest(Request request) { |
| 98 | + return request.RequestUri.Host.Contains("graph", StringComparison.OrdinalIgnoreCase); |
| 99 | + } |
| 100 | +} |
| 101 | +``` |
| 102 | + |
| 103 | +It is anticipated that the set of helper methods offered in the `ProxyUtils` class will grow and change over time. |
| 104 | + |
| 105 | +### Init |
| 106 | + |
| 107 | +This event provides the plugin with a `RootCommand` from `System.CommandLine`. This enables plugins to register options to read parameters from the supplied command line arguments using `Option<T>`. |
| 108 | + |
| 109 | +### OptionsLoaded |
| 110 | + |
| 111 | +This event provides the plugin with any values supplied for options registered on the `RootCommand` during `Init`. The internal configuration state should be updated with these options by implementers. |
| 112 | + |
| 113 | +### Request |
| 114 | + |
| 115 | +The event is fired when the proxy intercepts an HTTP request.Implementers should check the ResponseState on the suppied event args and the url the request is targeting against the set of urlsToWatch supplied during registration to determine if their plugin logic should execute. |
| 116 | + |
| 117 | +An helper method is provided on the event args object can be used like so: `e.ShouldExecute(this.urlsToWatch))` |
| 118 | + |
| 119 | +### Response |
| 120 | + |
| 121 | +This event is fired before a response is sent from the proxy to the request originator. |
| 122 | + |
| 123 | +This allows plugin implementers to modify the response being sent to the caller of the HTTP API. For example this could be used to modify response headers to simulate rate limit resource consumption. |
| 124 | + |
| 125 | +Again, implementers should check that the response against the urlsToWatch supplied during registration to determine if the plugin should execute. |
| 126 | + |
| 127 | +In this instance, the event args object supplies a `e.HasRequestUrlMatch(this.urlsToWatch))` helper method. |
| 128 | + |
| 129 | +## Sample plugin implementation |
| 130 | + |
| 131 | +```cs |
| 132 | +public class SelectGuidancePlugin : IProxyPlugin { |
| 133 | + private ISet<Regex>? _urlsToWatch; |
| 134 | + private ILogger? _logger; |
| 135 | + public string Name => nameof(SelectGuidancePlugin); |
| 136 | + |
| 137 | + public void Register(IPluginEvents pluginEvents, |
| 138 | + IProxyContext context, |
| 139 | + ISet<Regex> urlsToWatch, |
| 140 | + IConfigurationSection? configSection = null) { |
| 141 | + if (pluginEvents is null) { |
| 142 | + throw new ArgumentNullException(nameof(pluginEvents)); |
| 143 | + } |
| 144 | + |
| 145 | + if (context is null || context.Logger is null) { |
| 146 | + throw new ArgumentException($"{nameof(context)} must not be null and must supply a non-null Logger", nameof(context)); |
| 147 | + } |
| 148 | + |
| 149 | + if (urlsToWatch is null || urlsToWatch.Count == 0) { |
| 150 | + throw new ArgumentException($"{nameof(urlsToWatch)} cannot be null or empty", nameof(urlsToWatch)); |
| 151 | + } |
| 152 | + |
| 153 | + _urlsToWatch = urlsToWatch; |
| 154 | + _logger = context.Logger; |
| 155 | + |
| 156 | + pluginEvents.Request += OnRequest; |
| 157 | + } |
| 158 | + |
| 159 | + private void OnRequest(object? sender, ProxyRequestArgs e) { |
| 160 | + Request request = e.Session.HttpClient.Request; |
| 161 | + if (_urlsToWatch is not null && e.ShouldExecute(_urlsToWatch) && WarnNoSelect(request)) |
| 162 | + _logger?.LogWarn(BuildUseSelectMessage(request)); |
| 163 | + } |
| 164 | + |
| 165 | + private static bool WarnNoSelect(Request request) => |
| 166 | + ProxyUtils.IsGraphRequest(request) && |
| 167 | + request.Method == "GET" && |
| 168 | + !request.Url.Contains("$select", StringComparison.OrdinalIgnoreCase); |
| 169 | + |
| 170 | + private static string GetSelectParameterGuidanceUrl() => "https://learn.microsoft.com/graph/query-parameters#select-parameter"; |
| 171 | + private static string BuildUseSelectMessage(Request r) => $"To improve performance of your application, use the $select parameter when calling {r.RequestUriString}. More info at {GetSelectParameterGuidanceUrl()}"; |
| 172 | +} |
| 173 | +``` |
0 commit comments