diff --git a/ApiGateway.sln b/ApiGateway.sln index db76296..6a5d94e 100644 --- a/ApiGateway.sln +++ b/ApiGateway.sln @@ -38,6 +38,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution README.md = README.md Docs\README_ActionFilters.md = Docs\README_ActionFilters.md Docs\README_Authorization.md = Docs\README_Authorization.md + Docs\README_AzureFunctions.md = Docs\README_AzureFunctions.md Docs\README_ConfigSettings.md = Docs\README_ConfigSettings.md Docs\README_Customizations.md = Docs\README_Customizations.md Docs\README_EventSourcing.md = Docs\README_EventSourcing.md @@ -72,6 +73,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNetCore.ApiGateway.Minim EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ApiGateway.API.Minimal", "ApiGateway.API.Minimal\ApiGateway.API.Minimal.csproj", "{7238A13E-4DBD-4CAD-949C-FF4831F219FA}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNetCore.ApiGateway.AzureFunctions", "AspNetCore.ApiGateway.AzureFunctions\AspNetCore.ApiGateway.AzureFunctions.csproj", "{48C710DC-EA94-4E65-A4D6-0484900A867A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sample.ApiGateway.AzureFunctions", "Sample.ApiGateway.AzureFunctions\Sample.ApiGateway.AzureFunctions.csproj", "{05D846A9-C9BC-4EE1-84EA-30FD52F82F04}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -118,6 +123,14 @@ Global {7238A13E-4DBD-4CAD-949C-FF4831F219FA}.Debug|Any CPU.Build.0 = Debug|Any CPU {7238A13E-4DBD-4CAD-949C-FF4831F219FA}.Release|Any CPU.ActiveCfg = Release|Any CPU {7238A13E-4DBD-4CAD-949C-FF4831F219FA}.Release|Any CPU.Build.0 = Release|Any CPU + {48C710DC-EA94-4E65-A4D6-0484900A867A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {48C710DC-EA94-4E65-A4D6-0484900A867A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {48C710DC-EA94-4E65-A4D6-0484900A867A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {48C710DC-EA94-4E65-A4D6-0484900A867A}.Release|Any CPU.Build.0 = Release|Any CPU + {05D846A9-C9BC-4EE1-84EA-30FD52F82F04}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {05D846A9-C9BC-4EE1-84EA-30FD52F82F04}.Debug|Any CPU.Build.0 = Debug|Any CPU + {05D846A9-C9BC-4EE1-84EA-30FD52F82F04}.Release|Any CPU.ActiveCfg = Release|Any CPU + {05D846A9-C9BC-4EE1-84EA-30FD52F82F04}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -133,6 +146,8 @@ Global {4EEA8028-DAB4-4A8E-B4CF-BA95A52E3028} = {09FF380F-5D8D-417E-B16A-ABB9109FE882} {275933B1-2754-4584-8D93-64A9FBBACFD3} = {09FF380F-5D8D-417E-B16A-ABB9109FE882} {7238A13E-4DBD-4CAD-949C-FF4831F219FA} = {042DBA12-AE6F-43F3-8BC4-1204678F90D2} + {48C710DC-EA94-4E65-A4D6-0484900A867A} = {09FF380F-5D8D-417E-B16A-ABB9109FE882} + {05D846A9-C9BC-4EE1-84EA-30FD52F82F04} = {042DBA12-AE6F-43F3-8BC4-1204678F90D2} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D41EFF44-6827-41B7-9F6F-62057780D65C} diff --git a/AspNetCore.ApiGateway.AzureFunctions/.gitignore b/AspNetCore.ApiGateway.AzureFunctions/.gitignore new file mode 100644 index 0000000..ff5b00c --- /dev/null +++ b/AspNetCore.ApiGateway.AzureFunctions/.gitignore @@ -0,0 +1,264 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# Azure Functions localsettings file +local.settings.json + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# DNX +project.lock.json +project.fragment.lock.json +artifacts/ + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# TODO: Comment the next line if you want to checkin your web deploy settings +# but database connection strings (with potential passwords) will be unencrypted +#*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config +# NuGet v3's project.json files produces more ignoreable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +node_modules/ +orleans.codegen.cs + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush +.cr/ + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc \ No newline at end of file diff --git a/AspNetCore.ApiGateway.AzureFunctions/ApiGatewayConfigService.cs b/AspNetCore.ApiGateway.AzureFunctions/ApiGatewayConfigService.cs new file mode 100644 index 0000000..0523f20 --- /dev/null +++ b/AspNetCore.ApiGateway.AzureFunctions/ApiGatewayConfigService.cs @@ -0,0 +1,61 @@ +using Microsoft.Extensions.Configuration; + +namespace AspNetCore.ApiGateway.AzureFunctions +{ + public class ApiSetting + { + public string Identifier { get; set; } + public string ApiKey { get; set; } + public string[] BackendAPIBaseUrls { get; set; } + public RouteSetting[] Routes { get; set; } + public RouteSetting this[string routeIdentifier] + { + get + { + return Routes.Single(r => r.Identifier == routeIdentifier); + } + } + } + + public class RouteSetting + { + public string Identifier { get; set; } + public string RouteKey { get; set; } + public GatewayVerb Verb { get; set; } + public string BackendAPIRoutePath { get; set; } + public int ResponseCachingDurationInSeconds { get; set; } = -1; + } + + public interface IApiGatewayConfigService + { + ApiSetting this[string identifier] { get; } + } + + public class ApiGatewayConfigService : IApiGatewayConfigService + { + private IEnumerable Settings { get; set; } + + public ApiGatewayConfigService(IConfiguration configuration) + { + var settings = configuration.GetSection("Settings") + .Get>(); + + this.Settings = settings; + + ApiGatewayConfigProvider.MySettings = this; + } + + public ApiSetting this[string identifier] + { + get + { + return Settings.Single(s => s.Identifier == identifier); + } + } + } + + public static class ApiGatewayConfigProvider + { + public static IApiGatewayConfigService MySettings { get; set; } + } +} \ No newline at end of file diff --git a/AspNetCore.ApiGateway.AzureFunctions/ApiGatewayLog.cs b/AspNetCore.ApiGateway.AzureFunctions/ApiGatewayLog.cs new file mode 100644 index 0000000..c8cab42 --- /dev/null +++ b/AspNetCore.ApiGateway.AzureFunctions/ApiGatewayLog.cs @@ -0,0 +1,6 @@ +namespace AspNetCore.ApiGateway.AzureFunctions +{ + public class ApiGatewayLog + { + } +} diff --git a/AspNetCore.ApiGateway.AzureFunctions/ApiGatewayOptions.cs b/AspNetCore.ApiGateway.AzureFunctions/ApiGatewayOptions.cs new file mode 100644 index 0000000..f8c9f45 --- /dev/null +++ b/AspNetCore.ApiGateway.AzureFunctions/ApiGatewayOptions.cs @@ -0,0 +1,13 @@ +using System; +using System.Net.Http; + +namespace AspNetCore.ApiGateway.AzureFunctions +{ + public class ApiGatewayOptions + { + public bool UseResponseCaching { get; set; } + public ApiGatewayResponseCacheSettings ResponseCacheSettings { get; set; } + public Action DefaultHttpClientConfigure { get; set; } + public Func DefaultMyHttpClientHandler { get; set; } + } +} diff --git a/AspNetCore.ApiGateway.AzureFunctions/ApiGatewayRequestProcessor.cs b/AspNetCore.ApiGateway.AzureFunctions/ApiGatewayRequestProcessor.cs new file mode 100644 index 0000000..258bae1 --- /dev/null +++ b/AspNetCore.ApiGateway.AzureFunctions/ApiGatewayRequestProcessor.cs @@ -0,0 +1,119 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Web; + +namespace AspNetCore.ApiGateway.AzureFunctions +{ + public interface IApiGatewayRequestProcessor + { + IApiOrchestrator ApiOrchestrator { get; } + Task ProcessAsync(string apiKey, string routeKey, + HttpRequest httpRequest, + Func> backEndCall, + Func getContent = null, + object request = null, + string parameters = null); + } + + public class ApiGatewayRequestProcessor : IApiGatewayRequestProcessor + { + readonly IApiOrchestrator _apiOrchestrator; + readonly ILogger _logger; + readonly IHttpService _httpService; + + public IApiOrchestrator ApiOrchestrator => _apiOrchestrator; + + public ApiGatewayRequestProcessor(IApiOrchestrator apiOrchestrator, ILogger logger, IHttpService httpService) + { + _apiOrchestrator = apiOrchestrator; + _logger = logger; + _httpService = httpService; + } + + public async Task ProcessAsync( + string apiKey, + string routeKey, + HttpRequest httpRequest, + Func> backEndCall, + Func getContent = null, + object request = null, + string parameters = null) + { + if (parameters != null) + parameters = HttpUtility.UrlDecode(parameters); + else + parameters = string.Empty; + + _logger.LogApiInfo(apiKey, routeKey, parameters, request); + + var apiInfo = _apiOrchestrator.GetApi(apiKey, true); + + var gwRouteInfo = apiInfo.Mediator.GetRoute(routeKey); + + var routeInfo = gwRouteInfo.Route; + + if (routeInfo.Exec != null) + { + return await routeInfo.Exec(apiInfo, httpRequest); + } + else + { + using (var client = routeInfo.HttpClientConfig?.HttpClient()) + { + HttpContent content = null; + + if (request != null) + { + if (routeInfo.HttpClientConfig?.HttpContent != null) + { + content = routeInfo.HttpClientConfig.HttpContent(); + } + else + { + if (getContent != null) + { + content = getContent(request); + } + else + { + content = new StringContent(request.ToString(), Encoding.UTF8, "application/json"); + + content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + } + } + } + + httpRequest.Headers?.AddRequestHeaders((client ?? _httpService.Client).DefaultRequestHeaders); + + if (client == null) + { + routeInfo.HttpClientConfig?.CustomizeDefaultHttpClient?.Invoke(_httpService.Client, httpRequest); + } + + _logger.LogApiInfo($"{apiInfo.BaseUrl}{routeInfo.Path}{parameters}"); + + var response = await backEndCall((client ?? _httpService.Client), apiInfo, routeInfo, content); + + _logger.LogApiInfo($"{apiInfo.BaseUrl}{routeInfo.Path}{parameters}", false); + + response.EnsureSuccessStatusCode(); + + var returnedContent = await response.Content.ReadAsStringAsync(); + + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; + + return await Task.FromResult(routeInfo.ResponseType != null + ? !string.IsNullOrEmpty(returnedContent) ? JsonSerializer.Deserialize(returnedContent, routeInfo.ResponseType, options) : string.Empty + : returnedContent); + } + } + } + } +} diff --git a/AspNetCore.ApiGateway.AzureFunctions/ApiGatewayResponseCache.cs b/AspNetCore.ApiGateway.AzureFunctions/ApiGatewayResponseCache.cs new file mode 100644 index 0000000..04597ef --- /dev/null +++ b/AspNetCore.ApiGateway.AzureFunctions/ApiGatewayResponseCache.cs @@ -0,0 +1,56 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace AspNetCore.ApiGateway.AzureFunctions +{ + public class ApiGatewayResponseCacheSettings + { + // Gets or sets the value of the cache profile name. + public string CacheProfileName { get; set; } + // Gets or sets the duration in seconds for which the response is cached. This sets + // "max-age" in "Cache-control" header. + public int Duration { get; set; } = -1; + // Gets or sets the location where the data from a particular URL must be cached. + public ResponseCacheLocation Location { get; set; } + // Gets or sets the value which determines whether the data should be stored or + // not. When set to true, it sets "Cache-control" header to "no-store". Ignores + // the "Location" parameter for values other than "None". Ignores the "duration" + // parameter. + public bool NoStore { get; set; } + // Gets or sets the value for the Vary response header. + public string VaryByHeader { get; set; } + // Gets or sets the query keys to vary by. + public string[] VaryByQueryKeys { get; set; } + } + + internal class ResponseCacheTillAttribute : ResponseCacheAttribute + { + public ResponseCacheTillAttribute(IHttpContextAccessor httpContextAccessor, IApiOrchestrator apiOrchestrator, + ApiGatewayOptions apiGatewayOptions) + { + if (!apiGatewayOptions.UseResponseCaching) + { + base.NoStore = true; + return; + } + + var apiKey = httpContextAccessor.HttpContext.Request.RouteValues["apiKey"].ToString(); + var routeKey = httpContextAccessor.HttpContext.Request.RouteValues["routeKey"].ToString(); + + var api = apiOrchestrator.GetApi(apiKey); + var route = api.Mediator.GetRoute(routeKey); + + int duration = route.Route.ResponseCachingDurationInSeconds; + + if (duration >= 0) + { + base.Duration = duration; + } + else + { + base.Duration = apiGatewayOptions.ResponseCacheSettings.Duration; + } + } + } + +} diff --git a/AspNetCore.ApiGateway.AzureFunctions/ApiOrchestrator.cs b/AspNetCore.ApiGateway.AzureFunctions/ApiOrchestrator.cs new file mode 100644 index 0000000..16d58a1 --- /dev/null +++ b/AspNetCore.ApiGateway.AzureFunctions/ApiOrchestrator.cs @@ -0,0 +1,135 @@ +namespace AspNetCore.ApiGateway.AzureFunctions +{ + public class ApiInfo + { + public string BaseUrl { get; set; } + + public IMediator Mediator { get; set; } + } + + + internal class LoadBalancing + { + public LoadBalancingType Type { get; set; } = LoadBalancingType.Random; + public string[] BaseUrls { get; set; } + public int LastBaseUrlIndex { get; set; } = -1; + } + + public enum LoadBalancingType + { + Random, + RoundRobin + } + + public class ApiOrchestrator : IApiOrchestrator + { + Dictionary apis = new Dictionary(); + + Dictionary apiLoadBalancing = new Dictionary(); + + private static Random _random = new Random(); + private static readonly object _syncLock = new object(); + + private readonly IMediator _mediator; + + public ApiOrchestrator(IMediator mediator) + { + _mediator = mediator; + _mediator.ApiOrchestrator = this; + } + + public IMediator AddApi(string apiKey, params string[] baseUrls) + { + if (string.IsNullOrWhiteSpace(apiKey)) + { + throw new ArgumentException("Api key must be specified."); + } + if (baseUrls == null || baseUrls.Length == 0) + { + throw new ArgumentException("At least one base url must be specified."); + } + + var mediator = _mediator; + + apis.Add(apiKey.Trim().ToLower(), new ApiInfo() { BaseUrl = baseUrls.First(), Mediator = mediator }); + + apiLoadBalancing.Add(apiKey.ToLower(), new LoadBalancing { BaseUrls = baseUrls }); + + MediatorHelper.CurrentApiKey = apiKey; + + return mediator; + } + + public IMediator AddApi(string apiKey, LoadBalancingType loadBalancingType, params string[] baseUrls) + { + if (string.IsNullOrWhiteSpace(apiKey)) + { + throw new ArgumentException("Api key must be specified."); + } + if (baseUrls == null || baseUrls.Length == 0) + { + throw new ArgumentException("At least one base url must be specified."); + } + + var mediator = _mediator; + + apis.Add(apiKey.Trim().ToLower(), new ApiInfo() { BaseUrl = baseUrls.First(), Mediator = mediator }); + + apiLoadBalancing.Add(apiKey.ToLower(), new LoadBalancing { Type = loadBalancingType, BaseUrls = baseUrls }); + + MediatorHelper.CurrentApiKey = apiKey; + + return mediator; + } + + public ApiInfo GetApi(string apiKey, bool withLoadBalancing = false) + { + var apiInfo = apis[apiKey.ToLower()]; + + if (withLoadBalancing) + { + //if more than 1 base url is specified + //get the load balancing base url based on load balancing algorithm specified. + + var loadBalancing = apiLoadBalancing[apiKey.ToLower()]; + + if (loadBalancing.BaseUrls.Count() > 1) + { + apiInfo.BaseUrl = this.GetLoadBalancedUrl(loadBalancing); + } + } + + return apiInfo; + } + + public IEnumerable Orchestration => apis? + .GroupBy(x => x.Key).Select(x => new ApiOrchestration + { + ApiKey = x.Key, + ApiRoutes = x.First().Value.Mediator.Routes.Where(y => y.ApiKey == x.Key).ToList(), + Routes = x.First().Value.Mediator.Routes.Where(y => y.ApiKey == x.Key).ToList(), + }); + + private string GetLoadBalancedUrl(LoadBalancing loadBalancing) + { + if (loadBalancing.Type == LoadBalancingType.RoundRobin) + { + loadBalancing.LastBaseUrlIndex++; + + if (loadBalancing.LastBaseUrlIndex >= loadBalancing.BaseUrls.Length) + { + loadBalancing.LastBaseUrlIndex = 0; + } + return loadBalancing.BaseUrls[loadBalancing.LastBaseUrlIndex]; + } + else + { + lock(_syncLock) + { + var selected = _random.Next(0, loadBalancing.BaseUrls.Count()); + return loadBalancing.BaseUrls[selected]; + } + } + } + } +} diff --git a/AspNetCore.ApiGateway.AzureFunctions/AspNetCore.ApiGateway.AzureFunctions.csproj b/AspNetCore.ApiGateway.AzureFunctions/AspNetCore.ApiGateway.AzureFunctions.csproj new file mode 100644 index 0000000..da0e859 --- /dev/null +++ b/AspNetCore.ApiGateway.AzureFunctions/AspNetCore.ApiGateway.AzureFunctions.csproj @@ -0,0 +1,39 @@ + + + net8.0 + v4 + Library + enable + disable + + + + \ + PreserveNewest + True + + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + Never + + + + + + \ No newline at end of file diff --git a/AspNetCore.ApiGateway.AzureFunctions/CHANGELOG.md b/AspNetCore.ApiGateway.AzureFunctions/CHANGELOG.md new file mode 100644 index 0000000..effcb2b --- /dev/null +++ b/AspNetCore.ApiGateway.AzureFunctions/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog (Release Notes) + +- Added support for reading from appsettings.json. \ No newline at end of file diff --git a/AspNetCore.ApiGateway.AzureFunctions/Extensions.cs b/AspNetCore.ApiGateway.AzureFunctions/Extensions.cs new file mode 100644 index 0000000..a6362ec --- /dev/null +++ b/AspNetCore.ApiGateway.AzureFunctions/Extensions.cs @@ -0,0 +1,182 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Net.Http.Headers; +using System.Text.Json; + +namespace AspNetCore.ApiGateway.AzureFunctions +{ + public static class Extensions + { + static ApiGatewayOptions Options { get; set; } + + private static NewtonsoftJsonPatchInputFormatter GetJsonPatchInputFormatter() + { + var builder = new ServiceCollection() + .AddLogging() + .AddMvc() + .AddNewtonsoftJson() + .Services.BuildServiceProvider(); + + return builder + .GetRequiredService>() + .Value + .InputFormatters + .OfType() + .First(); + } + + public static void AddApiGateway(this IServiceCollection services, Action options = null) + { + Options = new ApiGatewayOptions(); + + options?.Invoke(Options); + + services.AddSingleton(); + + services.AddSingleton(sp => new ApiOrchestrator( + sp.GetRequiredService())); + + services.AddScoped(); + + if (Options.DefaultMyHttpClientHandler != null) + { + services + .AddHttpClient() + .ConfigurePrimaryHttpMessageHandler(Options.DefaultMyHttpClientHandler); + } + else if (Options.DefaultHttpClientConfigure != null) + { + services.AddHttpClient(Options.DefaultHttpClientConfigure); + } + else + { + services.AddHttpClient(); + } + + //services.AddApiGatewayResponseCaching(); + + //services.AddControllers() + // .AddNewtonsoftJson(); // Enables support for JsonPatchDocument + + //services.AddControllers(options => + //{ + // options.InputFormatters.Insert(0, GetJsonPatchInputFormatter()); + //}); + } + + public static void UseApiGateway(this IHost app, Action setApis) + { + var serviceProvider = app.Services; + var apiOrchestrator = serviceProvider.GetService(); + setApis(apiOrchestrator); + //if (Options != null) + //{ + // if (Options.UseResponseCaching) + // { + // app.UseResponseCaching(); + // } + //} + //app.UseMiddleware(); + + //var gatewayMiddleware = serviceProvider.GetServiceOrNull(); + + //if (gatewayMiddleware != null) + //{ + // app.UseMiddleware(); + //} + } + + internal static T GetServiceOrNull(this IServiceProvider serviceProvider) + where T: class + { + try + { + return serviceProvider.GetService(); + } + catch(Exception) + { + return null; + } + } + + internal static void AddApiGatewayResponseCaching(this IServiceCollection services) + { + if (Options != null) + { + if (Options.UseResponseCaching && Options.ResponseCacheSettings != null) + { + services.AddResponseCaching(); + + services.AddScoped(sp => new ResponseCacheTillAttribute(sp.GetRequiredService(), + sp.GetRequiredService(), + Options) + { + NoStore = Options.ResponseCacheSettings.NoStore, + Location = Options.ResponseCacheSettings.Location, + VaryByHeader = Options.ResponseCacheSettings.VaryByHeader, + VaryByQueryKeys = Options.ResponseCacheSettings.VaryByQueryKeys, + CacheProfileName = Options.ResponseCacheSettings.CacheProfileName + }); + } + else + { + services.AddScoped(sp => new ResponseCacheTillAttribute(sp.GetRequiredService(), + sp.GetRequiredService(), + Options) + { + NoStore = true + }); + } + } + } + + internal static void AddRequestHeaders (this IHeaderDictionary requestHeaders, HttpRequestHeaders headers) + { + foreach (var item in requestHeaders) + { + try + { + if (!headers.Contains(item.Key)) + headers.Add(item.Key, item.Value.ToString()); + } + catch(Exception) + { } + } + } + + internal static ApiOrchestration FilterRoutes(this ApiOrchestration orchestration, string key) + { + orchestration.ApiRoutes = orchestration.ApiRoutes.Where(y => y.RouteKey.Contains(key.Trim())).ToList(); + orchestration.Routes = orchestration.ApiRoutes; + return orchestration; + } + + internal static string ToUtcLongDateTime(this DateTime dateTime) + { + return dateTime.ToString("MM/dd/yyyy hh:mm:ss.fff tt"); + } + + internal static void LogApiInfo(this ILogger logger, string api, string key, string parameters, object request = null) + { + if (request != null) + logger.LogInformation($"ApiGateway: Incoming POST request. api: {api}, key: {key}, object: {JsonSerializer.Serialize(request)}, parameters: {parameters}, UtcTime: { DateTime.UtcNow.ToUtcLongDateTime() }"); + else + logger.LogInformation($"ApiGateway: Incoming POST request. api: {api}, key: {key}, UtcTime: { DateTime.UtcNow.ToUtcLongDateTime() }"); + } + + internal static void LogApiInfo(this ILogger logger, string url, bool beforeBackendCall = true) + { + if (beforeBackendCall) + logger.LogInformation($"ApiGateway: Calling back end. Url: {url}, UtcTime: { DateTime.UtcNow.ToUtcLongDateTime() }"); + else + logger.LogInformation($"ApiGateway: Finished calling back end. Url: {url}, UtcTime: { DateTime.UtcNow.ToUtcLongDateTime() }"); + } + + } +} diff --git a/AspNetCore.ApiGateway.AzureFunctions/GatewayConstants.cs b/AspNetCore.ApiGateway.AzureFunctions/GatewayConstants.cs new file mode 100644 index 0000000..c47ad53 --- /dev/null +++ b/AspNetCore.ApiGateway.AzureFunctions/GatewayConstants.cs @@ -0,0 +1,7 @@ +namespace AspNetCore.ApiGateway.AzureFunctions +{ + internal static class GatewayConstants + { + public static string GATEWAY_PATH_REGEX = "^/?api/Gateway(/(?!orchestration)(hub/)?(?.*?)/(?.*?)(/.*?)?)?$"; + } +} diff --git a/AspNetCore.ApiGateway.AzureFunctions/GatewayFunctions.cs b/AspNetCore.ApiGateway.AzureFunctions/GatewayFunctions.cs new file mode 100644 index 0000000..5f3d86d --- /dev/null +++ b/AspNetCore.ApiGateway.AzureFunctions/GatewayFunctions.cs @@ -0,0 +1,189 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using System.Text; +using System.Text.Json; + +namespace AspNetCore.ApiGateway.AzureFunctions +{ + public class GatewayFunctions + { + private readonly ILogger _logger; + private readonly IApiGatewayRequestProcessor _requestProcessor; + + public GatewayFunctions(IApiGatewayRequestProcessor requestProcessor, ILogger logger = null) + { + _logger = logger; + _requestProcessor = requestProcessor; + } + + [Function("ApiGateway-Head")] + public async Task HeadAsync([HttpTrigger(AuthorizationLevel.Function, "head", Route = "api/Gateway/{apiKey}/{routeKey}")] + HttpRequest request, string apiKey, string routeKey, string parameters = null) + { + _logger?.LogInformation("C# HTTP trigger function processed a request."); + + return new OkObjectResult(await _requestProcessor.ProcessAsync( + apiKey, + routeKey, + request, + (client, apiInfo, routeInfo, content) => client.GetAsync($"{apiInfo.BaseUrl}{(routeInfo.IsParameterizedRoute ? routeInfo.GetPath(request) : routeInfo.Path + parameters)}"), + null, + null, + parameters + )); + } + + [Function("ApiGateway-Get")] + public async Task GetAsync([HttpTrigger(AuthorizationLevel.Function, "get", Route = "api/Gateway/{apiKey}/{routeKey}")] + HttpRequest request, string apiKey, string routeKey, string parameters = null) + { + _logger?.LogInformation("C# HTTP trigger function processed a request."); + + return new OkObjectResult(await _requestProcessor.ProcessAsync( + apiKey, + routeKey, + request, + (client, apiInfo, routeInfo, content) => client.GetAsync($"{apiInfo.BaseUrl}{(routeInfo.IsParameterizedRoute ? routeInfo.GetPath(request) : routeInfo.Path + parameters)}"), + null, + null, + parameters + )); + } + + [Function("ApiGateway-Post")] + public async Task PostAsync([HttpTrigger(AuthorizationLevel.Function, "post", Route = "api/Gateway/{apiKey}/{routeKey}")] + HttpRequest request, string apiKey, string routeKey, string parameters = null) + { + _logger?.LogInformation("C# HTTP trigger function processed a request."); + + string requestBody = await new StreamReader(request.Body).ReadToEndAsync(); + object requestObj = JsonConvert.DeserializeObject(requestBody); + + return new OkObjectResult(await _requestProcessor.ProcessAsync( + apiKey, + routeKey, + request, + (client, apiInfo, routeInfo, content) => client.PostAsync($"{apiInfo.BaseUrl}{(routeInfo.IsParameterizedRoute ? routeInfo.GetPath(request) : routeInfo.Path + parameters)}", content), + null, + requestObj, + parameters + )); + } + + [Function("ApiGateway-Put")] + public async Task PutAsync([HttpTrigger(AuthorizationLevel.Function, "put", Route = "api/Gateway/{apiKey}/{routeKey}")] + HttpRequest request, string apiKey, string routeKey, string parameters = null) + { + _logger?.LogInformation("C# HTTP trigger function processed a request."); + + string requestBody = await new StreamReader(request.Body).ReadToEndAsync(); + object requestObj = JsonConvert.DeserializeObject(requestBody); + + return new OkObjectResult(await _requestProcessor.ProcessAsync( + apiKey, + routeKey, + request, + (client, apiInfo, routeInfo, content) => client.PutAsync($"{apiInfo.BaseUrl}{(routeInfo.IsParameterizedRoute ? routeInfo.GetPath(request) : routeInfo.Path + parameters)}", content), + null, + requestObj, + parameters + )); + } + + [Function("ApiGateway-Patch")] + public async Task PatchAsync([HttpTrigger(AuthorizationLevel.Function, "patch", Route = "api/Gateway/{apiKey}/{routeKey}")] + HttpRequest request, string apiKey, string routeKey, string parameters = null) + { + _logger?.LogInformation("C# HTTP trigger function processed a request."); + + using var reader = new StreamReader(request.Body); + + string body = await reader.ReadToEndAsync(); + + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + var patch = System.Text.Json.JsonSerializer.Deserialize(body, options); + + return new OkObjectResult(await _requestProcessor.ProcessAsync( + apiKey, + routeKey, + request, + (client, apiInfo, routeInfo, content) => client.PatchAsync($"{apiInfo.BaseUrl}{(routeInfo.IsParameterizedRoute ? routeInfo.GetPath(request) : routeInfo.Path + parameters)}", content), + request => + { + var p = System.Text.Json.JsonSerializer.Serialize(request); + + return new StringContent(p, Encoding.UTF8, "application/json-patch+json"); + }, + patch.operations, + parameters + )); + } + + [Function("ApiGateway-Delete")] + public async Task DeleteAsync([HttpTrigger(AuthorizationLevel.Function, "delete", Route = "api/Gateway/{apiKey}/{routeKey}")] + HttpRequest request, string apiKey, string routeKey, string parameters = null) + { + _logger?.LogInformation("C# HTTP trigger function processed a request."); + + return new OkObjectResult(await _requestProcessor.ProcessAsync( + apiKey, + routeKey, + request, + (client, apiInfo, routeInfo, content) => client.DeleteAsync($"{apiInfo.BaseUrl}{(routeInfo.IsParameterizedRoute ? routeInfo.GetPath(request) : routeInfo.Path + parameters)}"), + null, + null, + parameters + )); + } + + //[Function("ApiGateway-GetOrchestration")] + //public async Task GetOrchestrationAsync([HttpTrigger(AuthorizationLevel.Function, "get", Route = "/api/Gateway/orchestration")] + // HttpRequest request, string apiKey = null, string routeKey = null) + //{ + // _logger?.LogInformation("C# HTTP trigger function processed a request."); + + // var apiOrchestrator = _requestProcessor.ApiOrchestrator; + + // apiKey = apiKey?.ToLower(); + // routeKey = routeKey?.ToLower(); + + // var orchestrations = await Task.FromResult(string.IsNullOrEmpty(apiKey) && string.IsNullOrEmpty(routeKey) + // ? apiOrchestrator.Orchestration + // : (!string.IsNullOrEmpty(apiKey) && string.IsNullOrEmpty(routeKey) + // ? apiOrchestrator.Orchestration?.Where(x => x.ApiKey.Contains(apiKey.Trim())) + // : (string.IsNullOrEmpty(apiKey) && !string.IsNullOrEmpty(routeKey) + // ? apiOrchestrator.Orchestration?.Where(x => x.ApiRoutes.Any(y => y.RouteKey.Contains(routeKey.Trim()))) + // .Select(x => x.FilterRoutes(routeKey)) + // : apiOrchestrator.Orchestration?.Where(x => x.ApiKey.Contains(apiKey.Trim())) + // .Select(x => x.FilterRoutes(routeKey))))); + + // return Results.Ok(orchestrations); + //} + } + + public class ContractResolver + { + } + + public class Operation + { + public int value { get; set; } + public int operationType { get; set; } + public string path { get; set; } + public string op { get; set; } + public object from { get; set; } + } + + public class PatchObj + { + public List operations { get; set; } + public ContractResolver contractResolver { get; set; } + } +} diff --git a/AspNetCore.ApiGateway.AzureFunctions/HttpService.cs b/AspNetCore.ApiGateway.AzureFunctions/HttpService.cs new file mode 100644 index 0000000..1a5c834 --- /dev/null +++ b/AspNetCore.ApiGateway.AzureFunctions/HttpService.cs @@ -0,0 +1,17 @@ +namespace AspNetCore.ApiGateway.AzureFunctions +{ + public interface IHttpService + { + HttpClient Client { get; } + } + + public class HttpService : IHttpService + { + public HttpService(HttpClient client) + { + this.Client = client; + } + + public HttpClient Client { get; } + } +} diff --git a/AspNetCore.ApiGateway.AzureFunctions/IApiOrchestrator.cs b/AspNetCore.ApiGateway.AzureFunctions/IApiOrchestrator.cs new file mode 100644 index 0000000..8457d52 --- /dev/null +++ b/AspNetCore.ApiGateway.AzureFunctions/IApiOrchestrator.cs @@ -0,0 +1,12 @@ +namespace AspNetCore.ApiGateway.AzureFunctions +{ + public interface IApiOrchestrator + { + IMediator AddApi(string apiKey, params string[] baseUrl); + IMediator AddApi(string apiKey, LoadBalancingType loadBalancingType, params string[] baseUrls); + + ApiInfo GetApi(string apiKey, bool withLoadBalancing = false); + + IEnumerable Orchestration { get; } + } +} \ No newline at end of file diff --git a/AspNetCore.ApiGateway.AzureFunctions/IMediator.cs b/AspNetCore.ApiGateway.AzureFunctions/IMediator.cs new file mode 100644 index 0000000..5cd6004 --- /dev/null +++ b/AspNetCore.ApiGateway.AzureFunctions/IMediator.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Http; + +namespace AspNetCore.ApiGateway.AzureFunctions +{ + public interface IMediatorBase + { + IApiOrchestrator ApiOrchestrator { get; set; } + } + + public interface IMediator: IMediatorBase + { + IMediator AddRoute(string routeKey, GatewayVerb verb, RouteInfo routeInfo); + + IMediator AddRoute(string routeKey, GatewayVerb verb, Func> exec); + + GatewayRouteInfo GetRoute(string routeKey); + + IMediator AddApi(string apiKey, params string[] baseUrls); + + IEnumerable Routes { get; } + } +} \ No newline at end of file diff --git a/AspNetCore.ApiGateway.AzureFunctions/Mediator.cs b/AspNetCore.ApiGateway.AzureFunctions/Mediator.cs new file mode 100644 index 0000000..99b2194 --- /dev/null +++ b/AspNetCore.ApiGateway.AzureFunctions/Mediator.cs @@ -0,0 +1,191 @@ +using Microsoft.AspNetCore.Http; +using System.Text.RegularExpressions; + +namespace AspNetCore.ApiGateway.AzureFunctions +{ + public enum GatewayVerb + { + GET, + HEAD, + POST, + PUT, + PATCH, + DELETE + } + + public class HttpClientConfig + { + public Func HttpClient { get; set; } + + public Func HttpContent { get; set; } + + public Action CustomizeDefaultHttpClient { get; set; } + } + + public abstract class GatewayRouteInfoBase + { + public string ApiKey { get; set; } + } + + public class GatewayRouteInfo : GatewayRouteInfoBase + { + public GatewayVerb Verb { get; set; } + + public RouteInfo Route { get; set; } + } + + public class RouteInfo + { + private IEnumerable _routeParams = new List(); + private bool _isPathTypeDetermined = false; + private bool _isParameterizedRoute = false; + private string _path = string.Empty; + + public string Path + { + get => _path; + set + { + if (value == null) + { + throw new ArgumentNullException(nameof(Path)); + } + _path = value.Trim(); + } + } + internal bool IsParameterizedRoute + { + get + { + if (!_isPathTypeDetermined) + { + _isParameterizedRoute = Regex.Match(this.Path, @"\{.+?\}", RegexOptions.IgnoreCase | RegexOptions.Compiled).Success; + _isPathTypeDetermined=true; + } + return _isParameterizedRoute; + } + } + public Type ResponseType { get; set; } + public Type RequestType { get; set; } + public Func> Exec { get; set; } + public HttpClientConfig HttpClientConfig { get; set; } + public int ResponseCachingDurationInSeconds { get; set; } = -1; + private IEnumerable Params + { + get + { + if (_routeParams.Any()) + { + return _routeParams; + } + + var m = Regex.Match(this.Path, @"^.*?(\{(?.+?)\}.*?)*$", RegexOptions.IgnoreCase | RegexOptions.Compiled); + + if (m.Success) + { + _routeParams = m.Groups["param"].Captures.Select(x => x.Value).ToList(); + } + + return _routeParams; + } + } + internal string GetPath(HttpRequest request) + { + var p = request.Query["parameters"][0]; + + var allParams = p.Split('&'); + + var paramDictionary = allParams.Select(x => x.Split('=')); + + var paramValues = paramDictionary.Join(this.Params, x => x[0], y => y, (x, y) => new { Key = x[0], Value = x[1] }).ToList(); + + var path = new string(this.Path.ToCharArray()); + + paramValues.ForEach(x => + { + path = Regex.Replace(path, $"{{{x.Key}}}", x.Value); + }); + + return path; + } + } + + public class Mediator : IMediator + { + IApiOrchestrator _apiOrchestrator; + Dictionary paths = new Dictionary(); + + public IApiOrchestrator ApiOrchestrator + { + get => _apiOrchestrator; + set => _apiOrchestrator = value; + } + + public IMediator AddRoute(string routeKey, GatewayVerb verb, RouteInfo routeInfo) + { + if (string.IsNullOrWhiteSpace(routeKey)) + { + throw new ArgumentException("Value cannot be null or whitespace.", nameof(routeKey)); + } + + var gatewayRouteInfo = new GatewayRouteInfo + { + Verb = verb, + ApiKey = MediatorHelper.CurrentApiKey, + Route = routeInfo + }; + + paths.Add(routeKey.Trim().ToLower(), gatewayRouteInfo); + + return this; + } + + public IMediator AddRoute(string routeKey, GatewayVerb verb, Func> exec) + { + if (string.IsNullOrWhiteSpace(routeKey)) + { + throw new ArgumentException("Value cannot be null or whitespace.", nameof(routeKey)); + } + + if (exec == null) + { + throw new ArgumentNullException(nameof(exec)); + } + + var gatewayRouteInfo = new GatewayRouteInfo + { + Verb = verb, + ApiKey = MediatorHelper.CurrentApiKey, + Route = new RouteInfo + { + Exec = exec + } + }; + + paths.Add(routeKey.Trim().ToLower(), gatewayRouteInfo); + + return this; + } + + public IMediator AddApi(string apiKey, params string[] baseUrls) + { + _apiOrchestrator.AddApi(apiKey, baseUrls); + + return _apiOrchestrator.GetApi(apiKey).Mediator; + } + + public GatewayRouteInfo GetRoute(string key) + { + return paths[key.ToLower()]; + } + + public IEnumerable Routes => paths.Select(x => new Route + { + RouteKey = x.Key, + ApiKey = x.Value?.ApiKey, + Verb = x.Value?.Verb.ToString(), + DownstreamPath = x.Value?.Route?.Path?.ToString(), + }); + } + +} diff --git a/AspNetCore.ApiGateway.AzureFunctions/MediatorHelper.cs b/AspNetCore.ApiGateway.AzureFunctions/MediatorHelper.cs new file mode 100644 index 0000000..4957545 --- /dev/null +++ b/AspNetCore.ApiGateway.AzureFunctions/MediatorHelper.cs @@ -0,0 +1,7 @@ +namespace AspNetCore.ApiGateway.AzureFunctions +{ + internal static class MediatorHelper + { + public static string CurrentApiKey { get; set; } + } +} diff --git a/AspNetCore.ApiGateway.AzureFunctions/Orchestration.cs b/AspNetCore.ApiGateway.AzureFunctions/Orchestration.cs new file mode 100644 index 0000000..f8a2963 --- /dev/null +++ b/AspNetCore.ApiGateway.AzureFunctions/Orchestration.cs @@ -0,0 +1,58 @@ +using System.Text.Json.Serialization; + +namespace AspNetCore.ApiGateway.AzureFunctions +{ + public class Orchestration + { + [JsonPropertyOrder(1)] + [JsonPropertyName("apiKey")] + public string ApiKey { get; set; } + + [JsonIgnore()] + public List ApiRoutes { get; set; } + } + + public class ApiOrchestration : Orchestration + { + public ApiOrchestration() + { + } + + [JsonPropertyOrder(2)] + [JsonPropertyName("routes")] + public IEnumerable Routes { get; set; } + } + + public class RouteBase + { + [JsonPropertyOrder(1)] + [JsonPropertyName("routeKey")] + + public string RouteKey { get; set; } + + [JsonPropertyOrder(1)] + [JsonPropertyName("apiKey")] + [JsonIgnore()] + + public string ApiKey { get; set; } + } + + public class Route : RouteBase + { + [JsonPropertyOrder(3)] + [JsonPropertyName("verb")] + public string Verb { get; set; } + + [JsonPropertyOrder(4)] + [JsonPropertyName("downstreamPath")] + public string DownstreamPath { get; set; } + + //[JsonPropertyOrder(5)] + //[JsonPropertyName("requestJsonSchema")] + //public JsonSchema RequestJsonSchema { get; set; } + + //[JsonPropertyOrder(6)] + //[JsonPropertyName("responseJsonSchema")] + //public JsonSchema ResponseJsonSchema { get; set; } + } +} diff --git a/AspNetCore.ApiGateway.AzureFunctions/Program.cs b/AspNetCore.ApiGateway.AzureFunctions/Program.cs new file mode 100644 index 0000000..7bbaabd --- /dev/null +++ b/AspNetCore.ApiGateway.AzureFunctions/Program.cs @@ -0,0 +1,9 @@ +namespace AspNetCore.ApiGateway.AzureFunctions +{ + public class Program + { + public static void Main(string[] args) + { + } + } +} diff --git a/AspNetCore.ApiGateway.AzureFunctions/Properties/serviceDependencies.json b/AspNetCore.ApiGateway.AzureFunctions/Properties/serviceDependencies.json new file mode 100644 index 0000000..c264e8c --- /dev/null +++ b/AspNetCore.ApiGateway.AzureFunctions/Properties/serviceDependencies.json @@ -0,0 +1,7 @@ +{ + "dependencies": { + "appInsights1": { + "type": "appInsights" + } + } +} \ No newline at end of file diff --git a/AspNetCore.ApiGateway.AzureFunctions/Properties/serviceDependencies.local.json b/AspNetCore.ApiGateway.AzureFunctions/Properties/serviceDependencies.local.json new file mode 100644 index 0000000..5a956e8 --- /dev/null +++ b/AspNetCore.ApiGateway.AzureFunctions/Properties/serviceDependencies.local.json @@ -0,0 +1,7 @@ +{ + "dependencies": { + "appInsights1": { + "type": "appInsights.sdk" + } + } +} \ No newline at end of file diff --git a/AspNetCore.ApiGateway.AzureFunctions/README.md b/AspNetCore.ApiGateway.AzureFunctions/README.md new file mode 100644 index 0000000..74bb693 --- /dev/null +++ b/AspNetCore.ApiGateway.AzureFunctions/README.md @@ -0,0 +1,41 @@ +# API Gateway as a RESTful Minimal API Facade + +## Supports .NET 8/9/10. + +|Packages|Version|Downloads| +|---------------------------|:---:|:---:| +|*AspNetCore.ApiGateway.Client*|[![Nuget Version](https://img.shields.io/nuget/v/AspNetCore.ApiGateway.Client)](https://www.nuget.org/packages/AspNetCore.ApiGateway.Client)|[![Downloads count](https://img.shields.io/nuget/dt/AspNetCore.ApiGateway.Client)](https://www.nuget.org/packages/AspNetCore.ApiGateway.Client)| +|*ts-aspnetcore-apigateway-client*|[![NPM Version](https://img.shields.io/npm/v/ts-aspnetcore-apigateway-client)](https://www.npmjs.com/package/ts-aspnetcore-apigateway-client)|[![Downloads count](https://img.shields.io/npm/dy/ts-aspnetcore-apigateway-client)](https://www.npmjs.com/package/ts-aspnetcore-apigateway-client)| + +This project demonstrates how to implement an API Gateway using ASP.NET Core Minimal APIs. + +The API Gateway serves as a single entry point for clients, routing requests to various backend services while providing features such as authentication, logging, and response aggregation. + +### Framework to help build your own Api Gateway. Out of the box, simple to use facade. + +## Seamlessly transition your Minimal API skills to the API Gateway. + +# This project has been on-boarded by the .NET Foundation and endorsed as revolutionary. + +### Read more on the Project website + +## Features + +* Swagger +* Authorization +* Load balancing +* Response caching +* Request aggregation +* Middleware service +* Logging +* Clients available in + * .NET + * Typescript + +## Social Media + +Read more [**here**](https://www.linkedin.com/feed/update/urn:li:activity:7168255226624372736/) + +## Documentation + +Read more [**here**](https://github.com/VeritasSoftware/AspNetCore.ApiGateway#gateway-as-a-restful-minimal-api-facade) \ No newline at end of file diff --git a/AspNetCore.ApiGateway.AzureFunctions/host.json b/AspNetCore.ApiGateway.AzureFunctions/host.json new file mode 100644 index 0000000..ee5cf5f --- /dev/null +++ b/AspNetCore.ApiGateway.AzureFunctions/host.json @@ -0,0 +1,12 @@ +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + }, + "enableLiveMetricsFilters": true + } + } +} \ No newline at end of file diff --git a/AspNetCore.ApiGateway.Tests/AspNetCore.ApiGateway.Tests.csproj b/AspNetCore.ApiGateway.Tests/AspNetCore.ApiGateway.Tests.csproj index f85e554..9ed9e18 100644 --- a/AspNetCore.ApiGateway.Tests/AspNetCore.ApiGateway.Tests.csproj +++ b/AspNetCore.ApiGateway.Tests/AspNetCore.ApiGateway.Tests.csproj @@ -10,6 +10,7 @@ + @@ -26,6 +27,7 @@ + diff --git a/AspNetCore.ApiGateway.Tests/AzureFunctionsGatewayClientTests.cs b/AspNetCore.ApiGateway.Tests/AzureFunctionsGatewayClientTests.cs new file mode 100644 index 0000000..0ad7767 --- /dev/null +++ b/AspNetCore.ApiGateway.Tests/AzureFunctionsGatewayClientTests.cs @@ -0,0 +1,255 @@ +using AspNetCore.ApiGateway.Client; +using Microsoft.AspNetCore.JsonPatch; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Learn.AzureFunctionsTesting; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Xunit; + +namespace AspNetCore.ApiGateway.Tests +{ + [Collection("Azure Functions Gateway")] + public class AzureFunctionsGatewayClientTests : IClassFixture + { + private readonly IServiceProvider _serviceProvider; + + public AzureFunctionsGatewayClientTests(AzureFunctionsAPIInitialize apiInit, FunctionFixture fixture) + { + IServiceCollection services = new ServiceCollection(); + + //Wire up the Client for dependency injection using extension + services.AddApiGatewayClient(settings => settings.ApiGatewayBaseUrl = "http://localhost:7055"); + + _serviceProvider = services.BuildServiceProvider(); + } + + [Fact] + public async Task Test_Get_Pass() + { + //Arrange + + //Get Client from dependency injection container + var client = _serviceProvider.GetRequiredService(); + + var parameters = new ApiGatewayParameters + { + ApiKey = "weatherservice", + RouteKey = "forecast", + Headers = new Dictionary + { + { "Authorization", "bearer wq298cjwosos==" } + } + }; + + //Act + var forecasts = await client.GetAsync(parameters); + + //Assert + Assert.True(forecasts.Length > 0); + } + + [Fact] + public async Task Test_Get_WithParam_Pass() + { + //Arrange + + //Get Client from dependency injection container + var client = _serviceProvider.GetRequiredService(); + + var parameters = new ApiGatewayParameters + { + ApiKey = "weatherservice", + RouteKey = "type", + Parameters = "3", + Headers = new Dictionary + { + { "Authorization", "bearer wq298cjwosos==" } + } + }; + + //Act + var weatherType = await client.GetAsync(parameters); + + //Assert + Assert.NotNull(weatherType); + Assert.True(!string.IsNullOrEmpty(weatherType.Type)); + + ////Arrange + //parameters = new ApiGatewayParameters + //{ + // ApiKey = "weatherservice", + // RouteKey = "typewithparams", + // Parameters = "index=3", + // Headers = new Dictionary + // { + // { "Authorization", "bearer wq298cjwosos==" } + // } + //}; + + ////Act + //weatherType = await client.GetAsync(parameters); + + ////Assert + //Assert.NotNull(weatherType); + //Assert.True(!string.IsNullOrEmpty(weatherType.Type)); + } + + [Fact] + public async Task Test_Post_Pass() + { + //Arrange + + //Get Client from dependency injection container + var client = _serviceProvider.GetRequiredService(); + + AddWeatherTypeRequest payload = new AddWeatherTypeRequest + { + WeatherType = "Windy" + }; + + var parameters = new ApiGatewayParameters + { + ApiKey = "weatherservice", + RouteKey = "add", + Headers = new Dictionary + { + { "Authorization", "bearer wq298cjwosos==" } + } + }; + + //Act + var weatherTypes = await client.PostAsync(parameters, payload); + + //Assert + Assert.True(weatherTypes.Last() == "Windy"); + } + + [Fact] + public async Task Test_Put_Pass() + { + //Arrange + + //Get Client from dependency injection container + var client = _serviceProvider.GetRequiredService(); + + UpdateWeatherTypeRequest payload = new UpdateWeatherTypeRequest + { + WeatherType = "Coooooooool", + Index = 3 + }; + + var parameters = new ApiGatewayParameters + { + ApiKey = "weatherservice", + RouteKey = "update", + Headers = new Dictionary + { + { "Authorization", "bearer wq298cjwosos==" } + } + }; + + //Act + await client.PutAsync(parameters, payload); + + parameters = new ApiGatewayParameters + { + ApiKey = "weatherservice", + RouteKey = "types", + Headers = new Dictionary + { + { "Authorization", "bearer wq298cjwosos==" } + } + }; + + var weatherTypes = await client.GetAsync(parameters); + + //Assert + Assert.True(weatherTypes[3] == "Coooooooool"); + } + + [Fact] + public async Task Test_Patch_Pass() + { + //Arrange + + //Get Client from dependency injection container + var client = _serviceProvider.GetRequiredService(); + + JsonPatchDocument jsonPatch = new JsonPatchDocument(); + jsonPatch.Add(x => x.TemperatureC, 35); + + var parameters = new ApiGatewayParameters + { + ApiKey = "weatherservice", + RouteKey = "patch", + Headers = new Dictionary + { + { "Authorization", "bearer wq298cjwosos==" } + } + }; + + //Act + var weatherForecast = await client.PatchAsync(parameters, jsonPatch, true); + + //Assert + Assert.True(weatherForecast.TemperatureC == 35); + } + + [Fact] + public async Task Test_Delete_Pass() + { + //Arrange + + //Get Client from dependency injection container + var client = _serviceProvider.GetRequiredService(); + + var parameters = new ApiGatewayParameters + { + ApiKey = "weatherservice", + RouteKey = "remove", + Parameters = "0", + Headers = new Dictionary + { + { "Authorization", "bearer wq298cjwosos==" } + } + }; + + //Act + await client.DeleteAsync(parameters); + + parameters = new ApiGatewayParameters + { + ApiKey = "weatherservice", + RouteKey = "types", + Headers = new Dictionary + { + { "Authorization", "bearer wq298cjwosos==" } + } + }; + + var weatherTypes = await client.GetAsync(parameters); + + //Assert + Assert.DoesNotContain(weatherTypes, x => x == "Freezing"); + } + + //[Fact] + //public async Task Test_GetOrchestration_Pass() + //{ + // //Arrange + + // //Get Client from dependency injection container + // var client = _serviceProvider.GetRequiredService(); + + // var parameters = new ApiGatewayParameters(); + + // //Act + // var orchestrations = await client.GetOrchestrationAsync(parameters, true); + + // //Assert + // Assert.Equal(2, orchestrations.Count()); + //} + } +} diff --git a/AspNetCore.ApiGateway.Tests/AzureFunctionsGatewayTests.cs b/AspNetCore.ApiGateway.Tests/AzureFunctionsGatewayTests.cs new file mode 100644 index 0000000..4d30bb6 --- /dev/null +++ b/AspNetCore.ApiGateway.Tests/AzureFunctionsGatewayTests.cs @@ -0,0 +1,304 @@ +using AspNetCore.ApiGateway.Tests; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.JsonPatch; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Learn.AzureFunctionsTesting; +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading.Tasks; +using Xunit; +using StockAPI = Stock.API; +using WeatherAPI = Weather.API; + +[assembly: TestFramework("Microsoft.Learn.AzureFunctionsTesting.TestFramework", "Microsoft.Learn.AzureFunctionsTesting")] +[assembly: AssemblyFixture(typeof(FunctionFixture))] +namespace AspNetCore.ApiGateway.Tests +{ + public class FunctionStartup : IFunctionTestStartup + { + public void Configure(FunctionTestConfigurationBuilder builder) + { + var path = Path.Combine(Environment.CurrentDirectory, @"..\..\..\..\Sample.ApiGateway.AzureFunctions\bin\Debug\net8.0"); + builder.SetFunctionAppPath(path); + builder.SetFunctionAppPort(7055); + } + } + + // Define a collection for the FunctionFixture + [CollectionDefinition("Azure Functions Gateway")] + public class FunctionCollection : ICollectionFixture> + { + // This class has no code, and is never created. Its purpose is just to be the place to apply [CollectionDefinition] and all the ICollectionFixture<> interfaces. + } + + public class AzureFunctionsAPIInitialize + { + public TestServer GatewayAPI { get; set; } + + public AzureFunctionsAPIInitialize() + { + //Start Weather API + IWebHostBuilder weatherAPI = new WebHostBuilder() + .UseStartup() + .UseKestrel(options => options.Listen(IPAddress.Any, 5003, listenOptions => listenOptions.UseHttps(o => o.AllowAnyClientCertificate()))); + + weatherAPI.Start(); + + //Start Stock API + IWebHostBuilder stockAPI = new WebHostBuilder() + .UseStartup() + .UseKestrel(options => options.Listen(IPAddress.Any, 5005, listenOptions => listenOptions.UseHttps(o => o.AllowAnyClientCertificate()))); + + stockAPI.Start(); + } + } + + [Collection("Azure Functions Gateway")] + public class AzureFunctionsGatewayTests : IClassFixture + { + private readonly HttpClient _httpClient; + + public AzureFunctionsGatewayTests(AzureFunctionsAPIInitialize apiInit, FunctionFixture fixture) + { + _httpClient = fixture.Client; + } + + [Fact] + public async Task Test_Get_Pass() + { + var client = _httpClient; + + //Gateway API url with Api key and Route key + var gatewayUrl = "api/Gateway/weatherservice/forecast"; + + var response = await client.GetAsync(gatewayUrl); + + response.EnsureSuccessStatusCode(); + + var forecasts = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync()); + + Assert.True(forecasts.Length > 0); + } + + [Fact] + public async Task Test_Multiple_Get_Pass() + { + var client = _httpClient; + + //Gateway API url with Api key and Route key + //Weather API call + var gatewayUrl = "api/Gateway/weatherservice/forecast"; + + var response = await client.GetAsync(gatewayUrl); + + response.EnsureSuccessStatusCode(); + + var forecasts = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync()); + + Assert.True(forecasts.Length > 0); + + client = _httpClient; + + //Stock API call + gatewayUrl = "api/Gateway/stockservice/stocks"; + + response = await client.GetAsync(gatewayUrl); + + response.EnsureSuccessStatusCode(); + + var stockQuotes = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync()); + + Assert.True(stockQuotes.Length > 0); + } + + [Fact] + public async Task Test_Get_WithParam_Pass() + { + var client = _httpClient; + + //Gateway API url with Api key, Route key and Param + var gatewayUrl = "api/Gateway/weatherservice/type?parameters=3"; + + var response = await client.GetAsync(gatewayUrl); + + response.EnsureSuccessStatusCode(); + + var strResponse = await response.Content.ReadAsStringAsync(); + + var weatherType = JsonSerializer.Deserialize(strResponse); + + Assert.NotNull(weatherType); + Assert.True(!string.IsNullOrEmpty(weatherType.Type)); + + client = _httpClient; + + gatewayUrl = "api/Gateway/weatherservice/typewithparams?parameters=index=3"; + + response = await client.GetAsync(gatewayUrl); + + response.EnsureSuccessStatusCode(); + + strResponse = await response.Content.ReadAsStringAsync(); + + weatherType = JsonSerializer.Deserialize(strResponse); + + Assert.NotNull(weatherType); + Assert.True(!string.IsNullOrEmpty(weatherType.Type)); + } + + [Fact] + public async Task Test_Post_Pass() + { + var client = _httpClient; + + //Gateway API url with Api key and Route key + var gatewayUrl = $"api/Gateway/weatherservice/add"; + + AddWeatherTypeRequest request = new AddWeatherTypeRequest + { + WeatherType = "Windy" + }; + + // POST JSON + HttpResponseMessage response = await client.PostAsJsonAsync(gatewayUrl, request); + + response.EnsureSuccessStatusCode(); + + var weatherTypes = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync()); + + Assert.True(weatherTypes.Last() == "Windy"); + } + + [Fact] + public async Task Test_Put_Pass() + { + var client = _httpClient; + + //Gateway API url with Api key and Route key + var gatewayUrl = "api/Gateway/weatherservice/update"; + + UpdateWeatherTypeRequest request = new UpdateWeatherTypeRequest + { + WeatherType = "Coooooooool", + Index = 3 + }; + + // POST JSON + HttpResponseMessage response = await client.PutAsJsonAsync(gatewayUrl, request); + + response.EnsureSuccessStatusCode(); + + //Gateway API url with Api key and Route key + gatewayUrl = "api/Gateway/weatherservice/types"; + + response = await client.GetAsync(gatewayUrl); + + response.EnsureSuccessStatusCode(); + + var weatherTypes = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync()); + + Assert.True(weatherTypes[3] == "Coooooooool"); + } + + [Fact] + public async Task Test_Patch_Pass() + { + var client = _httpClient; + + //Gateway API url with Api key and Route key + var gatewayUrl = "api/Gateway/weatherservice/patch"; + + JsonPatchDocument jsonPatch = new JsonPatchDocument(); + jsonPatch.Add(x => x.TemperatureC, 35); + + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + var response = await client.PatchAsJsonAsync(gatewayUrl, jsonPatch, options); + + response.EnsureSuccessStatusCode(); + + var strResponse = await response.Content.ReadAsStringAsync(); + + var weatherForecast = JsonSerializer.Deserialize(strResponse); + + Assert.True(weatherForecast.TemperatureC == 35); + } + + [Fact] + public async Task Test_Delete_Pass() + { + var client = _httpClient; + + //Gateway API url with Api key, Route key and Param + var gatewayUrl = "api/Gateway/weatherservice/remove?parameters=0"; + + var response = await client.DeleteAsync(gatewayUrl); + + response.EnsureSuccessStatusCode(); + + //Gateway API url with Api key and Route key + gatewayUrl = "api/Gateway/weatherservice/types"; + + response = await client.GetAsync(gatewayUrl); + + response.EnsureSuccessStatusCode(); + + var weatherTypes = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync()); + + Assert.DoesNotContain(weatherTypes, x => x == "Freezing"); + } + + //[Fact] + //public async Task Test_Get_Invalid_ApiKey_Fail() + //{ + // var client = _httpClient; + + // //Gateway API url with invalid Api key and Route key + // var gatewayUrl = "api/Gateway/xyzservice/forecast"; + + // var response = await client.GetAsync(gatewayUrl); + + // Assert.True(response.StatusCode == HttpStatusCode.NotFound); + //} + + //[Fact] + //public async Task Test_Get_Invalid_RouteKey_Fail() + //{ + // var client = _httpClient; + + // //Gateway API url with Api key and invalid Route key + // var gatewayUrl = "api/Gateway/weatherservice/xyz"; + + // var response = await client.GetAsync(gatewayUrl); + + // Assert.True(response.StatusCode == HttpStatusCode.NotFound); + //} + + //[Fact] + //public async Task Test_GetOrchestration_Pass() + //{ + // var client = _httpClient; + + // //Gateway API Orchestration url + // var gatewayUrl = "api/Gateway/orchestration"; + + // var response = await client.GetAsync(gatewayUrl); + + // response.EnsureSuccessStatusCode(); + + // var strResponse = await response.Content.ReadAsStringAsync(); + + // var orchestration = JsonSerializer.Deserialize(strResponse); + + // Assert.True(orchestration.Length > 0); + //} + } +} diff --git a/Docs/APIGatewayAzureFunctions.png b/Docs/APIGatewayAzureFunctions.png new file mode 100644 index 0000000..e8d62db Binary files /dev/null and b/Docs/APIGatewayAzureFunctions.png differ diff --git a/Docs/ApiGatewayAzureFunctionCall.png b/Docs/ApiGatewayAzureFunctionCall.png new file mode 100644 index 0000000..d063576 Binary files /dev/null and b/Docs/ApiGatewayAzureFunctionCall.png differ diff --git a/Docs/README_AzureFunctions.md b/Docs/README_AzureFunctions.md new file mode 100644 index 0000000..443dcba --- /dev/null +++ b/Docs/README_AzureFunctions.md @@ -0,0 +1,193 @@ +# API Gateway as Azure Functions Facade + +The API Gateway is engineered as a Azure Functions facade. + +You can hook up **Authorization** etc. just like any Azure Function. + +|Packages|Version|Downloads| +|---------------------------|:---:|:---:| +|*Veritas.AspNetCore.ApiGateway.AzureFunctions*|[![Nuget Version](https://img.shields.io/nuget/v/Veritas.AspNetCore.ApiGateway.AzureFunctions)](https://www.nuget.org/packages/Veritas.AspNetCore.ApiGateway.AzureFunctions)|[![Downloads count](https://img.shields.io/nuget/dt/Veritas.AspNetCore.ApiGateway.AzureFunctions)](https://www.nuget.org/packages/Veritas.AspNetCore.ApiGateway.AzureFunctions)| +|*AspNetCore.ApiGateway.Client*|[![Nuget Version](https://img.shields.io/nuget/v/AspNetCore.ApiGateway.Client)](https://www.nuget.org/packages/AspNetCore.ApiGateway.Client)|[![Downloads count](https://img.shields.io/nuget/dt/AspNetCore.ApiGateway.Client)](https://www.nuget.org/packages/AspNetCore.ApiGateway.Client)| +|*ts-aspnetcore-apigateway-client*|[![NPM Version](https://img.shields.io/npm/v/ts-aspnetcore-apigateway-client)](https://www.npmjs.com/package/ts-aspnetcore-apigateway-client)|[![Downloads count](https://img.shields.io/npm/dy/ts-aspnetcore-apigateway-client)](https://www.npmjs.com/package/ts-aspnetcore-apigateway-client)| + +## Features + +* Authorization +* Load balancing +* Response caching +* Request aggregation +* Logging +* Clients available in + * .NET + * Typescript + +### Your **Gateway API** are Azure Functions which exposes endpoints that are a **facade** over your backend API endpoints. + +* HEAD +* GET +* POST +* PUT +* PATCH +* DELETE + +API Gateway Facade + +## Implementation + +In the solution, there are 2 **back end APIs** : **Weather API** and **Stock API**. + +For eg. To make a GET call to the backend API, you would set up an Api and a GET Route in your Gateway API's **Api Orchestrator**. + +Then, the client app would make a GET call to the Gateway API which would make a GET call to the backend API using HttpClient. + +## In your Backend API + +Let us say you have a GET endpoint like this. + +* **HTTP GET - /weatherforecast/forecast** + +## In your Gateway API + +You add a Route for the backend GET call in the **Api Orchrestrator**. + +You create a backend API with ApiKey called **weatherservice** for eg. +And, a Route with RouteKey called **forecast** for eg. + +So, the call to the Gateway would become: + +* **HTTP GET - /weatherservice/forecast** + +**Add a reference to the package and...** + +* Create an **Api Orchestration**. + + You create an Api (weatherservice) and add a Route (forecast). + +```C# +public static class ApiOrchestration +{ + public static void Create(IApiOrchestrator orchestrator, IHost app) + { + var serviceProvider = app.Services; + + var weatherService = serviceProvider.GetRequiredService(); + + var weatherApiClientConfig = weatherService.GetClientConfig(); + + orchestrator.AddApi("weatherservice", "https://localhost:5003/") + //Get + .AddRoute("forecast", GatewayVerb.GET, new RouteInfo { Path = "weatherforecast/forecast", ResponseType = typeof(IEnumerable) }) + //Head + .AddRoute("forecasthead", GatewayVerb.HEAD, new RouteInfo { Path = "weatherforecast/forecast" }) + //Get with params + .AddRoute("typewithparams", GatewayVerb.GET, new RouteInfo { Path = "weatherforecast/types/{index}" }) + //Get using custom HttpClient + .AddRoute("types", GatewayVerb.GET, new RouteInfo { Path = "weatherforecast/types", ResponseType = typeof(string[]), HttpClientConfig = weatherApiClientConfig }) + //Get with param using custom HttpClient + .AddRoute("type", GatewayVerb.GET, new RouteInfo { Path = "weatherforecast/types/", ResponseType = typeof(WeatherTypeResponse), HttpClientConfig = weatherApiClientConfig }) + //Get using custom implementation + .AddRoute("forecast-custom", GatewayVerb.GET, weatherService.GetForecast) + //Post + .AddRoute("add", GatewayVerb.POST, new RouteInfo { Path = "weatherforecast/types/add", RequestType = typeof(AddWeatherTypeRequest), ResponseType = typeof(string[]) }) + //Put + .AddRoute("update", GatewayVerb.PUT, new RouteInfo { Path = "weatherforecast/types/update", RequestType = typeof(UpdateWeatherTypeRequest), ResponseType = typeof(string[]) }) + //Patch + .AddRoute("patch", GatewayVerb.PATCH, new RouteInfo { Path = "weatherforecast/forecast/patch", ResponseType = typeof(WeatherForecast) }) + //Delete + .AddRoute("remove", GatewayVerb.DELETE, new RouteInfo { Path = "weatherforecast/types/remove/", ResponseType = typeof(string[]) }) + .AddApi("stockservice", "https://localhost:5005/") + .AddRoute("stocks", GatewayVerb.GET, new RouteInfo { Path = "stock", ResponseType = typeof(IEnumerable) }) + .AddRoute("stock", GatewayVerb.GET, new RouteInfo { Path = "stock/", ResponseType = typeof(StockQuote) }); + } +} +``` + +* Hook up in Startup.cs + +```C# +public void ConfigureServices(IServiceCollection services) +{ + services.AddTransient(); + + //services.AddSingleton(); + + //Api gateway + services.AddApiGateway(options => + { + options.UseResponseCaching = false; + options.ResponseCacheSettings = new ApiGatewayResponseCacheSettings + { + Duration = 60, //default for all routes + Location = ResponseCacheLocation.Any, + //Use VaryByQueryKeys to vary the response for each apiKey & routeKey + VaryByQueryKeys = new[] { "apiKey", "routeKey" } + }; + }); +} + +public void Configure(IHost app) +{ + //Api gateway + app.UseApiGateway(orchestrator => ApiOrchestration.Create(orchestrator, app)); +} +``` + +The Functions start as shown below: + +![API Gateway Azure Functions](/Docs/APIGatewayAzureFunctions.png) + +To call the **forecast** Route on the **weather service** Api, + +you can enter the **Api key** and **Route key** into Swagger as below: + +![API Gateway Postman call](/Docs/ApiGatewayAzureFunctionCall.png) + +This will hit the **weatherforecast/forecast** endpoint on the backend Weather API. + +### Using appsettings.json + +If you want, you can keep the **ApiKey, RouteKey, backend API base urls and Route path**, + +in the **appsettings.json**, read it using a Config Service, + +and pass it to the Api Orchestrator in the Create method. + +Read [**more**](/Docs/README_ConfigSettings.md). + +### Verbs usage & Routing + +You can check out how the Api Gateway supported Verbs are used & Routing below. + +### [Verbs Usage & Routing](Docs/README_VERBS.md) + +### Customizations + +* Create a new or customize the default **HttpClient** used by all the routes, to hit the backend Api. +* Create a new or customize the default **HttpClient** which each route uses to hit the backend Api. +* Use your own custom implementation to hit the backend Api. + +For **Request aggregation**, see this section. + +### [Customizations](Docs/README_Customizations.md) + +### Load Balancing + +### [Load Balancing](Docs/README_LoadBalancing.md) + +## Clients + +The Api Gateway supports a fixed set of endpoints. + +All routes go through these endpoints. + +The Client application has to talk to these endpoints of the Api Gateway. + +A Client library is provided for: + +* [**.Net**](Docs/README_Net_Client.md) + +* [**Typescript**](Docs/README_Typescript_Client.md) + +## Making requests to Azure Functions Gateway + +These [**Tests**](/AspNetCore.ApiGateway.Tests/AzureFunctionsGatewayTests.cs) show how to make calls to the Azure Functions Gateway. \ No newline at end of file diff --git a/Docs/README_MinimalAPI.md b/Docs/README_MinimalAPI.md index cbfa97f..da8ef80 100644 --- a/Docs/README_MinimalAPI.md +++ b/Docs/README_MinimalAPI.md @@ -66,127 +66,126 @@ So, the call to the Gateway would become: You create an Api (weatherservice) and add a Route (forecast). ```C# - public static class ApiOrchestration +public static class ApiOrchestration +{ + public static void Create(IApiOrchestrator orchestrator, IApplicationBuilder app) { - public static void Create(IApiOrchestrator orchestrator, IApplicationBuilder app) - { - var serviceProvider = app.ApplicationServices; - - var weatherService = serviceProvider.GetService(); - - var weatherApiClientConfig = weatherService.GetClientConfig(); - - orchestrator.AddApi("weatherservice", "https://localhost:5003/") - //Get - .AddRoute("forecast", GatewayVerb.GET, new RouteInfo { Path = "weatherforecast/forecast", ResponseType = typeof(IEnumerable) }) - //Head - .AddRoute("forecasthead", GatewayVerb.HEAD, new RouteInfo { Path = "weatherforecast/forecast" }) - //Get with params - .AddRoute("typewithparams", GatewayVerb.GET, new RouteInfo { Path = "weatherforecast/types/{index}" }) - //Get using custom HttpClient - .AddRoute("types", GatewayVerb.GET, new RouteInfo { Path = "weatherforecast/types", ResponseType = typeof(string[]), HttpClientConfig = weatherApiClientConfig }) - //Get with param using custom HttpClient - .AddRoute("type", GatewayVerb.GET, new RouteInfo { Path = "weatherforecast/types/", ResponseType = typeof(WeatherTypeResponse), HttpClientConfig = weatherApiClientConfig }) - //Get using custom implementation - .AddRoute("forecast-custom", GatewayVerb.GET, weatherService.GetForecast) - //Post - .AddRoute("add", GatewayVerb.POST, new RouteInfo { Path = "weatherforecast/types/add", RequestType = typeof(AddWeatherTypeRequest), ResponseType = typeof(string[]) }) - //Put - .AddRoute("update", GatewayVerb.PUT, new RouteInfo { Path = "weatherforecast/types/update", RequestType = typeof(UpdateWeatherTypeRequest), ResponseType = typeof(string[]) }) - //Patch - .AddRoute("patch", GatewayVerb.PATCH, new RouteInfo { Path = "weatherforecast/forecast/patch", ResponseType = typeof(WeatherForecast) }) - //Delete - .AddRoute("remove", GatewayVerb.DELETE, new RouteInfo { Path = "weatherforecast/types/remove/", ResponseType = typeof(string[]) }) - .AddApi("stockservice", "https://localhost:5005/") - .AddRoute("stocks", GatewayVerb.GET, new RouteInfo { Path = "stock", ResponseType = typeof(IEnumerable) }) - .AddRoute("stock", GatewayVerb.GET, new RouteInfo { Path = "stock/", ResponseType = typeof(StockQuote) }); - } + var serviceProvider = app.ApplicationServices; + + var weatherService = serviceProvider.GetService(); + + var weatherApiClientConfig = weatherService.GetClientConfig(); + + orchestrator.AddApi("weatherservice", "https://localhost:5003/") + //Get + .AddRoute("forecast", GatewayVerb.GET, new RouteInfo { Path = "weatherforecast/forecast", ResponseType = typeof(IEnumerable) }) + //Head + .AddRoute("forecasthead", GatewayVerb.HEAD, new RouteInfo { Path = "weatherforecast/forecast" }) + //Get with params + .AddRoute("typewithparams", GatewayVerb.GET, new RouteInfo { Path = "weatherforecast/types/{index}" }) + //Get using custom HttpClient + .AddRoute("types", GatewayVerb.GET, new RouteInfo { Path = "weatherforecast/types", ResponseType = typeof(string[]), HttpClientConfig = weatherApiClientConfig }) + //Get with param using custom HttpClient + .AddRoute("type", GatewayVerb.GET, new RouteInfo { Path = "weatherforecast/types/", ResponseType = typeof(WeatherTypeResponse), HttpClientConfig = weatherApiClientConfig }) + //Get using custom implementation + .AddRoute("forecast-custom", GatewayVerb.GET, weatherService.GetForecast) + //Post + .AddRoute("add", GatewayVerb.POST, new RouteInfo { Path = "weatherforecast/types/add", RequestType = typeof(AddWeatherTypeRequest), ResponseType = typeof(string[]) }) + //Put + .AddRoute("update", GatewayVerb.PUT, new RouteInfo { Path = "weatherforecast/types/update", RequestType = typeof(UpdateWeatherTypeRequest), ResponseType = typeof(string[]) }) + //Patch + .AddRoute("patch", GatewayVerb.PATCH, new RouteInfo { Path = "weatherforecast/forecast/patch", ResponseType = typeof(WeatherForecast) }) + //Delete + .AddRoute("remove", GatewayVerb.DELETE, new RouteInfo { Path = "weatherforecast/types/remove/", ResponseType = typeof(string[]) }) + .AddApi("stockservice", "https://localhost:5005/") + .AddRoute("stocks", GatewayVerb.GET, new RouteInfo { Path = "stock", ResponseType = typeof(IEnumerable) }) + .AddRoute("stock", GatewayVerb.GET, new RouteInfo { Path = "stock/", ResponseType = typeof(StockQuote) }); } +} ``` * Hook up in Startup.cs ```C# - public void ConfigureServices(IServiceCollection services) +public void ConfigureServices(IServiceCollection services) +{ + //Hook up GatewayHub using SignalR + services.AddCors(options => options.AddPolicy("CorsPolicy", builder => { - //Hook up GatewayHub using SignalR - services.AddCors(options => options.AddPolicy("CorsPolicy", builder => - { - builder - .AllowAnyMethod() - .AllowAnyHeader() - .AllowAnyOrigin(); - })); + builder + .AllowAnyMethod() + .AllowAnyHeader() + .AllowAnyOrigin(); + })); - services.AddTransient(); + services.AddTransient(); - //services.AddSingleton(); + //services.AddSingleton(); - //Api gateway - services.AddApiGateway(options => + //Api gateway + services.AddApiGateway(options => + { + options.UseResponseCaching = false; + options.ResponseCacheSettings = new ApiGatewayResponseCacheSettings { - options.UseResponseCaching = false; - options.ResponseCacheSettings = new ApiGatewayResponseCacheSettings - { - Duration = 60, //default for all routes - Location = ResponseCacheLocation.Any, - //Use VaryByQueryKeys to vary the response for each apiKey & routeKey - VaryByQueryKeys = new[] { "apiKey", "routeKey" } - }; - }); - - services.AddEndpointsApiExplorer(); // Required for Minimal APIs - services.AddSwaggerGen(c => + Duration = 60, //default for all routes + Location = ResponseCacheLocation.Any, + //Use VaryByQueryKeys to vary the response for each apiKey & routeKey + VaryByQueryKeys = new[] { "apiKey", "routeKey" } + }; + }); + + services.AddEndpointsApiExplorer(); // Required for Minimal APIs + services.AddSwaggerGen(c => + { + c.SwaggerDoc("v1", new OpenApiInfo { - c.SwaggerDoc("v1", new OpenApiInfo - { - Title = "My Minimal API Gateway", - Description = "A simple example of ASP.NET Core Minimal API with Swagger", - Version = "v1" - }); + Title = "My Minimal API Gateway", + Description = "A simple example of ASP.NET Core Minimal API with Swagger", + Version = "v1" }); + }); - services.AddMvc(); - } + services.AddMvc(); +} - //public void Configure(IApplicationBuilder app, WebApplication webApplication) - public void Configure(IApplicationBuilder app) +public void Configure(IApplicationBuilder app) +{ + app.UseSwagger(); + app.UseSwaggerUI(c => { - app.UseSwagger(); - app.UseSwaggerUI(c => - { - c.SwaggerEndpoint("/swagger/v1/swagger.json", "My Minimal API Gateway V1"); - // Optional: set the UI to load at the app root URL - // c.RoutePrefix = string.Empty; - }); + c.SwaggerEndpoint("/swagger/v1/swagger.json", "My Minimal API Gateway V1"); + // Optional: set the UI to load at the app root URL + // c.RoutePrefix = string.Empty; + }); - //webApplication. - app.UseCors("CorsPolicy"); + //webApplication. + app.UseCors("CorsPolicy"); - //Api gateway - app.UseApiGateway(orchestrator => ApiOrchestration.Create(orchestrator, app)); + //Api gateway + app.UseApiGateway(orchestrator => ApiOrchestration.Create(orchestrator, app)); - app.UseHttpsRedirection(); + app.UseHttpsRedirection(); - app.UseRouting(); + app.UseRouting(); - app.UseAuthorization(); + app.UseAuthorization(); - app.UseEndpoints(endpoints => - { - // Api Gateway Minimal API - endpoints.MapApiGatewayHead(); - endpoints.MapApiGatewayGet(); - endpoints.MapApiGatewayPost(); - endpoints.MapApiGatewayPut(); - endpoints.MapApiGatewayDelete(); - endpoints.MapApiGatewayPatch(); - endpoints.MapApiGatewayGetOrchestration(); - - // Maps all the endpoints - //endpoints.MapAllApiGatewayEndpoints(); - }); - } + app.UseEndpoints(endpoints => + { + // Api Gateway Minimal API + endpoints.MapApiGatewayHead(); + endpoints.MapApiGatewayGet(); + endpoints.MapApiGatewayPost(); + endpoints.MapApiGatewayPut(); + endpoints.MapApiGatewayDelete(); + endpoints.MapApiGatewayPatch(); + endpoints.MapApiGatewayGetOrchestration(); + + // Maps all the endpoints + //endpoints.MapAllApiGatewayEndpoints(); + }); +} ``` The Gateway Swagger appears as shown below: diff --git a/README.md b/README.md index 9a61880..644782e 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ The framework is available as a * **RESTful Microservice Facade.** * **RESTful Minimal API Facade.** +* **Azure Functions Facade.** ## Gateway as a RESTful Minimal API Facade @@ -50,6 +51,16 @@ The API Gateway has be implemented as a Minimal API, which acts as a facade over Read [**more**](/Docs/README_MinimalAPI.md) +## Gateway as a Azure Functions Facade + +The API Gateway has be implemented as Azure Functions, which acts as a facade over your backend microservices. + +Real easy to integrate the library into a Functions project. + +Seamlessly transition you Azure Functions knowledge to the Api Gateway. + +Read [**more**](/Docs/README_AzureFunctions.md) + ## Gateway as a RESTful Microservice Facade ## Features diff --git a/Sample.ApiGateway.AzureFunctions/.gitignore b/Sample.ApiGateway.AzureFunctions/.gitignore new file mode 100644 index 0000000..ff5b00c --- /dev/null +++ b/Sample.ApiGateway.AzureFunctions/.gitignore @@ -0,0 +1,264 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# Azure Functions localsettings file +local.settings.json + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# DNX +project.lock.json +project.fragment.lock.json +artifacts/ + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# TODO: Comment the next line if you want to checkin your web deploy settings +# but database connection strings (with potential passwords) will be unencrypted +#*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config +# NuGet v3's project.json files produces more ignoreable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +node_modules/ +orleans.codegen.cs + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush +.cr/ + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc \ No newline at end of file diff --git a/Sample.ApiGateway.AzureFunctions/ApiOrchestration.cs b/Sample.ApiGateway.AzureFunctions/ApiOrchestration.cs new file mode 100644 index 0000000..534953b --- /dev/null +++ b/Sample.ApiGateway.AzureFunctions/ApiOrchestration.cs @@ -0,0 +1,43 @@ +using AspNetCore.ApiGateway.AzureFunctions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Sample.ApiGateway.AzureFunctions +{ + public static class ApiOrchestration + { + public static void Create(IApiOrchestrator orchestrator, IHost app) + { + var serviceProvider = app.Services; + + var weatherService = serviceProvider.GetRequiredService(); + + var weatherApiClientConfig = weatherService.GetClientConfig(); + + orchestrator.AddApi("weatherservice", "https://localhost:5003/") + //Get + .AddRoute("forecast", GatewayVerb.GET, new RouteInfo { Path = "weatherforecast/forecast", ResponseType = typeof(IEnumerable) }) + //Head + .AddRoute("forecasthead", GatewayVerb.HEAD, new RouteInfo { Path = "weatherforecast/forecast" }) + //Get with params + .AddRoute("typewithparams", GatewayVerb.GET, new RouteInfo { Path = "weatherforecast/types/{index}" }) + //Get using custom HttpClient + .AddRoute("types", GatewayVerb.GET, new RouteInfo { Path = "weatherforecast/types", ResponseType = typeof(string[]), HttpClientConfig = weatherApiClientConfig }) + //Get with param using custom HttpClient + .AddRoute("type", GatewayVerb.GET, new RouteInfo { Path = "weatherforecast/types/", ResponseType = typeof(WeatherTypeResponse), HttpClientConfig = weatherApiClientConfig }) + //Get using custom implementation + .AddRoute("forecast-custom", GatewayVerb.GET, weatherService.GetForecast) + //Post + .AddRoute("add", GatewayVerb.POST, new RouteInfo { Path = "weatherforecast/types/add", RequestType = typeof(AddWeatherTypeRequest), ResponseType = typeof(string[]) }) + //Put + .AddRoute("update", GatewayVerb.PUT, new RouteInfo { Path = "weatherforecast/types/update", RequestType = typeof(UpdateWeatherTypeRequest), ResponseType = typeof(string[]) }) + //Patch + .AddRoute("patch", GatewayVerb.PATCH, new RouteInfo { Path = "weatherforecast/forecast/patch", ResponseType = typeof(WeatherForecast) }) + //Delete + .AddRoute("remove", GatewayVerb.DELETE, new RouteInfo { Path = "weatherforecast/types/remove/", ResponseType = typeof(string[]) }) + .AddApi("stockservice", "https://localhost:5005/") + .AddRoute("stocks", GatewayVerb.GET, new RouteInfo { Path = "stock", ResponseType = typeof(IEnumerable) }) + .AddRoute("stock", GatewayVerb.GET, new RouteInfo { Path = "stock/", ResponseType = typeof(StockQuote) }); + } + } +} diff --git a/Sample.ApiGateway.AzureFunctions/Function1.cs b/Sample.ApiGateway.AzureFunctions/Function1.cs new file mode 100644 index 0000000..87a0f91 --- /dev/null +++ b/Sample.ApiGateway.AzureFunctions/Function1.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.Logging; + +namespace Sample.ApiGateway.AzureFunctions +{ + public class Function1 + { + private readonly ILogger _logger; + + public Function1(ILogger logger) + { + _logger = logger; + } + + [Function("Function1")] + public IActionResult Run([HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequest req) + { + _logger.LogInformation("C# HTTP trigger function processed a request."); + return new OkObjectResult("Welcome to Azure Functions!"); + } + } +} diff --git a/Sample.ApiGateway.AzureFunctions/IWeatherService.cs b/Sample.ApiGateway.AzureFunctions/IWeatherService.cs new file mode 100644 index 0000000..7a97dfe --- /dev/null +++ b/Sample.ApiGateway.AzureFunctions/IWeatherService.cs @@ -0,0 +1,12 @@ +using AspNetCore.ApiGateway; +using AspNetCore.ApiGateway.AzureFunctions; +using Microsoft.AspNetCore.Http; + +namespace Sample.ApiGateway.AzureFunctions +{ + public interface IWeatherService + { + HttpClientConfig GetClientConfig(); + Task GetForecast(ApiInfo apiInfo, HttpRequest request); + } +} \ No newline at end of file diff --git a/Sample.ApiGateway.AzureFunctions/Models/AddWeatherTypeRequest.cs b/Sample.ApiGateway.AzureFunctions/Models/AddWeatherTypeRequest.cs new file mode 100644 index 0000000..56c275b --- /dev/null +++ b/Sample.ApiGateway.AzureFunctions/Models/AddWeatherTypeRequest.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; + +namespace Sample.ApiGateway.AzureFunctions +{ + public class AddWeatherTypeRequest + { + [JsonPropertyName("weatherType")] + public string WeatherType { get; set; } + } +} diff --git a/Sample.ApiGateway.AzureFunctions/Models/StockQuote.cs b/Sample.ApiGateway.AzureFunctions/Models/StockQuote.cs new file mode 100644 index 0000000..407c1c7 --- /dev/null +++ b/Sample.ApiGateway.AzureFunctions/Models/StockQuote.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace Sample.ApiGateway.AzureFunctions +{ + public class StockQuote + { + [JsonPropertyName("companyName")] + public string CompanyName { get; set; } + + [JsonPropertyName("costPerShare")] + public string CostPerShare { get; set; } + } +} diff --git a/Sample.ApiGateway.AzureFunctions/Models/UpdateWeatherTypeRequest.cs b/Sample.ApiGateway.AzureFunctions/Models/UpdateWeatherTypeRequest.cs new file mode 100644 index 0000000..b6b3132 --- /dev/null +++ b/Sample.ApiGateway.AzureFunctions/Models/UpdateWeatherTypeRequest.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace Sample.ApiGateway.AzureFunctions +{ + public class UpdateWeatherTypeRequest + { + [JsonPropertyName("weatherType")] + public string WeatherType { get; set; } + + [JsonPropertyName("index")] + public int Index { get; set; } + } +} diff --git a/Sample.ApiGateway.AzureFunctions/Models/WeatherForecast.cs b/Sample.ApiGateway.AzureFunctions/Models/WeatherForecast.cs new file mode 100644 index 0000000..04ed6c1 --- /dev/null +++ b/Sample.ApiGateway.AzureFunctions/Models/WeatherForecast.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace Sample.ApiGateway.AzureFunctions +{ + public class WeatherForecast + { + [JsonPropertyName("date")] + public DateTime Date { get; set; } + + [JsonPropertyName("temperatureC")] + public int TemperatureC { get; set; } + + [JsonPropertyName("temperatureF")] + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); + + [JsonPropertyName("summary")] + public string Summary { get; set; } + } +} diff --git a/Sample.ApiGateway.AzureFunctions/Models/WeatherTypeResponse.cs b/Sample.ApiGateway.AzureFunctions/Models/WeatherTypeResponse.cs new file mode 100644 index 0000000..3f60c4d --- /dev/null +++ b/Sample.ApiGateway.AzureFunctions/Models/WeatherTypeResponse.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; + +namespace Sample.ApiGateway.AzureFunctions +{ + public class WeatherTypeResponse + { + [JsonPropertyName("type")] + public string Type { get; set; } + } +} diff --git a/Sample.ApiGateway.AzureFunctions/Program.cs b/Sample.ApiGateway.AzureFunctions/Program.cs new file mode 100644 index 0000000..926cbb5 --- /dev/null +++ b/Sample.ApiGateway.AzureFunctions/Program.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Azure.Functions.Worker.Builder; +using Microsoft.Extensions.Hosting; +using Sample.ApiGateway.AzureFunctions; + +//var builder = FunctionsApplication.CreateBuilder(args); + +//builder.ConfigureFunctionsWebApplication(); + +//// Application Insights isn't enabled by default. See https://aka.ms/AAt8mw4. +//// builder.Services +//// .AddApplicationInsightsTelemetryWorkerService() +//// .ConfigureFunctionsApplicationInsights(); + +//builder.Build().Run(); + +var builder = FunctionsApplication.CreateBuilder(args); + +builder.ConfigureFunctionsWebApplication(); + +//builder.WebHost.ConfigureAppConfiguration(configBuilder => +//{ +// configBuilder.SetBasePath(Environment.CurrentDirectory) +// .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) +// .AddJsonFile($"appsettings.{Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")}.json", optional: true)//To specify environment +// .AddEnvironmentVariables();//You can add if you need to read environment variables. +//}); + +var startup = new Startup(builder.Configuration); +startup.ConfigureServices(builder.Services); + +var app = builder.Build(); +startup.Configure(app); + +app.Run(); diff --git a/Sample.ApiGateway.AzureFunctions/Properties/serviceDependencies.json b/Sample.ApiGateway.AzureFunctions/Properties/serviceDependencies.json new file mode 100644 index 0000000..c264e8c --- /dev/null +++ b/Sample.ApiGateway.AzureFunctions/Properties/serviceDependencies.json @@ -0,0 +1,7 @@ +{ + "dependencies": { + "appInsights1": { + "type": "appInsights" + } + } +} \ No newline at end of file diff --git a/Sample.ApiGateway.AzureFunctions/Properties/serviceDependencies.local.json b/Sample.ApiGateway.AzureFunctions/Properties/serviceDependencies.local.json new file mode 100644 index 0000000..5a956e8 --- /dev/null +++ b/Sample.ApiGateway.AzureFunctions/Properties/serviceDependencies.local.json @@ -0,0 +1,7 @@ +{ + "dependencies": { + "appInsights1": { + "type": "appInsights.sdk" + } + } +} \ No newline at end of file diff --git a/Sample.ApiGateway.AzureFunctions/Sample.ApiGateway.AzureFunctions.csproj b/Sample.ApiGateway.AzureFunctions/Sample.ApiGateway.AzureFunctions.csproj new file mode 100644 index 0000000..6631e63 --- /dev/null +++ b/Sample.ApiGateway.AzureFunctions/Sample.ApiGateway.AzureFunctions.csproj @@ -0,0 +1,37 @@ + + + net8.0 + v4 + Exe + enable + enable + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + Never + + + + + + \ No newline at end of file diff --git a/Sample.ApiGateway.AzureFunctions/Startup.cs b/Sample.ApiGateway.AzureFunctions/Startup.cs new file mode 100644 index 0000000..9db375b --- /dev/null +++ b/Sample.ApiGateway.AzureFunctions/Startup.cs @@ -0,0 +1,46 @@ +using AspNetCore.ApiGateway; +using AspNetCore.ApiGateway.AzureFunctions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Sample.ApiGateway.AzureFunctions +{ + public class Startup + { + public Startup(IConfiguration configuration) + { + Configuration = configuration; + } + + public IConfiguration Configuration { get; } + + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + { + services.AddTransient(); + + //services.AddSingleton(); + + //Api gateway + services.AddApiGateway(options => + { + options.UseResponseCaching = false; + options.ResponseCacheSettings = new ApiGatewayResponseCacheSettings + { + Duration = 60, //default for all routes + Location = ResponseCacheLocation.Any, + //Use VaryByQueryKeys to vary the response for each apiKey & routeKey + VaryByQueryKeys = new[] { "apiKey", "routeKey" } + }; + }); + } + + public void Configure(IHost app) + { + //Api gateway + app.UseApiGateway(orchestrator => ApiOrchestration.Create(orchestrator, app)); + } + } +} diff --git a/Sample.ApiGateway.AzureFunctions/WeatherService.cs b/Sample.ApiGateway.AzureFunctions/WeatherService.cs new file mode 100644 index 0000000..a85ea2c --- /dev/null +++ b/Sample.ApiGateway.AzureFunctions/WeatherService.cs @@ -0,0 +1,47 @@ +using AspNetCore.ApiGateway; +using AspNetCore.ApiGateway.AzureFunctions; +using Microsoft.AspNetCore.Http; +using System.Text.Json; + +namespace Sample.ApiGateway.AzureFunctions +{ + public class WeatherService : IWeatherService + { + /// + /// If you want to customize the default HttpClient or + /// use your own custom HttpClient or HttpContent + /// to hit the backend Api call, you can do this. + /// + /// + public HttpClientConfig GetClientConfig() + { + return new HttpClientConfig() + { + //customize the default HttpClient. eg. add a header. + CustomizeDefaultHttpClient = (httpClient, request) => httpClient.DefaultRequestHeaders.Add("My header", "My header value"), + //OR + //your own custom HttpClient + HttpClient = () => new HttpClient() + }; + } + + /// + /// If you want to completely customize your backend Api call, you can do this + /// + /// The api info + /// The gateway's incoming request + /// + public async Task GetForecast(ApiInfo apiInfo, HttpRequest request) + { + //Create your own implementation to hit the backend. + using (var client = new HttpClient()) + { + var response = await client.GetAsync($"{apiInfo.BaseUrl}weatherforecast/forecast"); + + response.EnsureSuccessStatusCode(); + + return JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync()); + } + } + } +} diff --git a/Sample.ApiGateway.AzureFunctions/host.json b/Sample.ApiGateway.AzureFunctions/host.json new file mode 100644 index 0000000..391770d --- /dev/null +++ b/Sample.ApiGateway.AzureFunctions/host.json @@ -0,0 +1,21 @@ +{ + "version": "2.0", + "functionTimeout": "00:10:00", + "extensions": { + "http": { + "routePrefix": "" + }, + "https": { + "routePrefix": "" + } + }, + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + }, + "enableLiveMetricsFilters": true + } + } +} \ No newline at end of file