diff --git a/docs/EmulatorPolicyChecklist.md b/docs/EmulatorPolicyChecklist.md index 71d1fd94..b53c7d85 100644 --- a/docs/EmulatorPolicyChecklist.md +++ b/docs/EmulatorPolicyChecklist.md @@ -61,7 +61,7 @@ Track progress of emulator policy handler implementation. Each policy needs a ha | ⬜ | RateLimitByKey | RateLimitByKeyHandler.cs | No-op + callbacks | `emulator/rate-limit-by-key` | | ✅ | RewriteUri | RewriteUriHandler.cs | Context mutation | `emulator/rewrite-uri` | | ⬜ | SendRequest | SendRequestHandler.cs | External service mock | `emulator/send-request` | -| ⬜ | SetBackendService | SetBackendServiceHandler.cs | Context mutation | `emulator/set-backend-service` | +| ✅ | SetBackendService | SetBackendServiceHandler.cs | Context mutation | `emulator/set-backend-service` | | ⬜ | ValidateJwt | ValidateJwtHandler.cs | Validation + short-circuit | `emulator/validate-jwt` | ## Existing Handlers Missing Tests Only diff --git a/src/Testing/Document/TestDocumentExtensions.cs b/src/Testing/Document/TestDocumentExtensions.cs index a6217d9e..ab83204e 100644 --- a/src/Testing/Document/TestDocumentExtensions.cs +++ b/src/Testing/Document/TestDocumentExtensions.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring; @@ -32,6 +32,9 @@ public static ResponseExampleStore SetupResponseExampleStore(this TestDocument d public static LoggerStore SetupLoggerStore(this TestDocument document) => document.Context.LoggerStore; + public static BackendStore SetupBackendStore(this TestDocument document) => + document.Context.BackendStore; + public static RateLimitStore SetupRateLimitStore(this TestDocument document) => document.Context.RateLimitStore; } \ No newline at end of file diff --git a/src/Testing/Emulator/BadRuntimeConfigurationException.cs b/src/Testing/Emulator/BadRuntimeConfigurationException.cs new file mode 100644 index 00000000..2b630ed8 --- /dev/null +++ b/src/Testing/Emulator/BadRuntimeConfigurationException.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Testing.Emulator; + +public class BadRuntimeConfigurationException(string message) : Exception(message) +{ + public required string Policy { get; init; } +} diff --git a/src/Testing/Emulator/Data/Backend.cs b/src/Testing/Emulator/Data/Backend.cs new file mode 100644 index 00000000..93f67871 --- /dev/null +++ b/src/Testing/Emulator/Data/Backend.cs @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Testing.Emulator.Data; + +public record Backend(string Id, string Url); diff --git a/src/Testing/Emulator/Data/BackendStore.cs b/src/Testing/Emulator/Data/BackendStore.cs new file mode 100644 index 00000000..3434723e --- /dev/null +++ b/src/Testing/Emulator/Data/BackendStore.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Testing.Emulator.Data; + +public class BackendStore +{ + private readonly Dictionary _backends = new(); + + public BackendStore Add(string id, string url) + { + var backend = new Backend(id, url); + Add(backend); + return this; + } + + public void Add(Backend backend) + { + if (!_backends.TryAdd(backend.Id, backend)) + { + throw new Exception($"Backend with id '{backend.Id}' already exists."); + } + } + + public bool TryGet(string id, [NotNullWhen(true)] out Backend? backend) => + _backends.TryGetValue(id, out backend); +} diff --git a/src/Testing/Emulator/Policies/SetBackendServiceHandler.cs b/src/Testing/Emulator/Policies/SetBackendServiceHandler.cs index 3f049cbf..e16a9140 100644 --- a/src/Testing/Emulator/Policies/SetBackendServiceHandler.cs +++ b/src/Testing/Emulator/Policies/SetBackendServiceHandler.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring; +using Microsoft.Azure.ApiManagement.PolicyToolkit.Testing.Expressions; namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Testing.Emulator.Policies; @@ -17,6 +18,34 @@ internal class SetBackendServiceHandler : PolicyHandler protected override void Handle(GatewayContext context, SetBackendServiceConfig config) { - throw new NotImplementedException(); + if (config.BaseUrl is not null) + { + SetServiceUrl(context, config.BaseUrl); + } + else if (config.BackendId is not null) + { + if (!context.BackendStore.TryGet(config.BackendId, out var backend)) + { + throw new BadRuntimeConfigurationException( + $"Backend with id '{config.BackendId}' could not be found.") + { + Policy = PolicyName + }; + } + + SetServiceUrl(context, backend.Url); + } + } + + private static void SetServiceUrl(GatewayContext context, string url) + { + var uri = new Uri(url); + context.Api.ServiceUrl = new MockUrl + { + Scheme = uri.Scheme, + Host = uri.Host, + Port = uri.Port.ToString(), + Path = uri.AbsolutePath + }; } } \ No newline at end of file diff --git a/src/Testing/Emulator/SectionContextProxy.cs b/src/Testing/Emulator/SectionContextProxy.cs index 3e7a35ff..276dc83d 100644 --- a/src/Testing/Emulator/SectionContextProxy.cs +++ b/src/Testing/Emulator/SectionContextProxy.cs @@ -46,6 +46,7 @@ public static SectionContextProxy Create(GatewayContext expressionCont } catch (FinishSectionProcessingException) { throw; } catch (PolicyException) { throw; } + catch (BadRuntimeConfigurationException) { throw; } catch (Exception e) { throw new PolicyException(e) { Policy = targetMethod.Name, Section = _sectionName, PolicyArgs = args }; diff --git a/src/Testing/GatewayContext.cs b/src/Testing/GatewayContext.cs index 5c93f78d..63df4454 100644 --- a/src/Testing/GatewayContext.cs +++ b/src/Testing/GatewayContext.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring; @@ -18,6 +18,7 @@ public class GatewayContext : MockExpressionContext internal readonly CacheStore CacheStore = new(); internal readonly ResponseExampleStore ResponseExampleStore = new(); internal readonly LoggerStore LoggerStore = new(); + internal readonly BackendStore BackendStore = new(); internal readonly RateLimitStore RateLimitStore = new(); public GatewayContext() diff --git a/test/Test.Testing/Emulator/Policies/SetBackendServiceTests.cs b/test/Test.Testing/Emulator/Policies/SetBackendServiceTests.cs new file mode 100644 index 00000000..92ad5ad9 --- /dev/null +++ b/test/Test.Testing/Emulator/Policies/SetBackendServiceTests.cs @@ -0,0 +1,141 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring; +using Microsoft.Azure.ApiManagement.PolicyToolkit.Testing; +using Microsoft.Azure.ApiManagement.PolicyToolkit.Testing.Document; +using Microsoft.Azure.ApiManagement.PolicyToolkit.Testing.Emulator; + +namespace Test.Emulator.Emulator.Policies; + +[TestClass] +public class SetBackendServiceTests +{ + class SetBackendServiceWithBaseUrl : IDocument + { + public void Inbound(IInboundContext context) + { + context.SetBackendService(new SetBackendServiceConfig + { + BaseUrl = "https://new-backend.example.com/api" + }); + } + + public void Backend(IBackendContext context) + { + context.SetBackendService(new SetBackendServiceConfig + { + BaseUrl = "https://backend-section.example.com" + }); + } + + public void Outbound(IOutboundContext context) + { + context.SetBackendService(new SetBackendServiceConfig + { + BaseUrl = "https://outbound-backend.example.com" + }); + } + + public void OnError(IOnErrorContext context) + { + context.SetBackendService(new SetBackendServiceConfig + { + BaseUrl = "https://error-backend.example.com" + }); + } + } + + class SetBackendServiceWithBackendId : IDocument + { + public void Inbound(IInboundContext context) + { + context.SetBackendService(new SetBackendServiceConfig + { + BackendId = "my-backend" + }); + } + } + + [TestMethod] + public void SetBackendService_BaseUrl_Inbound() + { + var test = new SetBackendServiceWithBaseUrl().AsTestDocument(); + + test.RunInbound(); + + test.Context.Api.ServiceUrl.Scheme.Should().Be("https"); + test.Context.Api.ServiceUrl.Host.Should().Be("new-backend.example.com"); + test.Context.Api.ServiceUrl.Path.Should().Be("/api"); + } + + [TestMethod] + public void SetBackendService_BaseUrl_Backend() + { + var test = new SetBackendServiceWithBaseUrl().AsTestDocument(); + + test.RunBackend(); + + test.Context.Api.ServiceUrl.Host.Should().Be("backend-section.example.com"); + } + + [TestMethod] + public void SetBackendService_BaseUrl_Outbound() + { + var test = new SetBackendServiceWithBaseUrl().AsTestDocument(); + + test.RunOutbound(); + + test.Context.Api.ServiceUrl.Host.Should().Be("outbound-backend.example.com"); + } + + [TestMethod] + public void SetBackendService_BaseUrl_OnError() + { + var test = new SetBackendServiceWithBaseUrl().AsTestDocument(); + + test.RunOnError(); + + test.Context.Api.ServiceUrl.Host.Should().Be("error-backend.example.com"); + } + + [TestMethod] + public void SetBackendService_BackendId_ResolvesFromStore() + { + var test = new SetBackendServiceWithBackendId().AsTestDocument(); + test.SetupBackendStore().Add("my-backend", "https://resolved-backend.example.com/v2"); + + test.RunInbound(); + + test.Context.Api.ServiceUrl.Host.Should().Be("resolved-backend.example.com"); + test.Context.Api.ServiceUrl.Path.Should().Be("/v2"); + } + + [TestMethod] + public void SetBackendService_BackendId_NotFound_Throws() + { + var test = new SetBackendServiceWithBackendId().AsTestDocument(); + + var act = () => test.RunInbound(); + + act.Should().Throw() + .Which.Message.Should().Contain("my-backend"); + } + + [TestMethod] + public void SetBackendService_Callback() + { + var test = new SetBackendServiceWithBaseUrl().AsTestDocument(); + var callbackExecuted = false; + test.SetupInbound().SetBackendService().WithCallback((_, config) => + { + callbackExecuted = true; + config.BaseUrl.Should().Be("https://new-backend.example.com/api"); + }); + + test.RunInbound(); + + callbackExecuted.Should().BeTrue(); + test.Context.Api.ServiceUrl.Host.Should().NotBe("new-backend.example.com"); + } +}