From 4dd3a88b007f09a484342ca3c8344440ab2cda9c Mon Sep 17 00:00:00 2001 From: Daniel Slocum Date: Tue, 18 Mar 2025 16:32:44 -0400 Subject: [PATCH] Add rate limiter middleware --- .../Middleware/RateLimiterMiddlewareTests.cs | 74 +++++++++++++ RateLimiter.Tests/RateLimiter.Tests.csproj | 7 ++ .../Rules/RequestPerTimeSpanRuleTests.cs | 84 +++++++++++++++ .../TimeSpanSinceLastRequestRuleTests.cs | 100 ++++++++++++++++++ .../Services/RateLimiterServiceTests.cs | 97 +++++++++++++++++ .../Middleware/RateLimiterExtensions.cs | 41 +++++++ .../Middleware/RateLimiterMiddleware.cs | 51 +++++++++ RateLimiter/Models/RateLimiterOptions.cs | 18 ++++ RateLimiter/RateLimiter.csproj | 4 + RateLimiter/Rules/IRateLimitRule.cs | 18 ++++ RateLimiter/Rules/RequestPerTimeSpanRule.cs | 45 ++++++++ .../Rules/TimeSpanSinceLastRequestRule.cs | 40 +++++++ RateLimiter/Services/IRateLimiterService.cs | 12 +++ RateLimiter/Services/RateLimiterService.cs | 62 +++++++++++ 14 files changed, 653 insertions(+) create mode 100644 RateLimiter.Tests/Middleware/RateLimiterMiddlewareTests.cs create mode 100644 RateLimiter.Tests/Rules/RequestPerTimeSpanRuleTests.cs create mode 100644 RateLimiter.Tests/Rules/TimeSpanSinceLastRequestRuleTests.cs create mode 100644 RateLimiter.Tests/Services/RateLimiterServiceTests.cs create mode 100644 RateLimiter/Middleware/RateLimiterExtensions.cs create mode 100644 RateLimiter/Middleware/RateLimiterMiddleware.cs create mode 100644 RateLimiter/Models/RateLimiterOptions.cs create mode 100644 RateLimiter/Rules/IRateLimitRule.cs create mode 100644 RateLimiter/Rules/RequestPerTimeSpanRule.cs create mode 100644 RateLimiter/Rules/TimeSpanSinceLastRequestRule.cs create mode 100644 RateLimiter/Services/IRateLimiterService.cs create mode 100644 RateLimiter/Services/RateLimiterService.cs diff --git a/RateLimiter.Tests/Middleware/RateLimiterMiddlewareTests.cs b/RateLimiter.Tests/Middleware/RateLimiterMiddlewareTests.cs new file mode 100644 index 00000000..3cd65317 --- /dev/null +++ b/RateLimiter.Tests/Middleware/RateLimiterMiddlewareTests.cs @@ -0,0 +1,74 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Moq; +using Xunit; +using RateLimiter.Middleware; +using RateLimiter.Services; + +namespace RateLimiter.Tests.Middleware; + +public class RateLimiterMiddlewareTests +{ + [Fact] + public async Task InvokeAsync_ShouldReturn401_WhenTokenIsMissing() + { + // Arrange + var rateLimiterMock = new Mock(); + var middleware = new RateLimiterMiddleware(async (context) => { await Task.CompletedTask; }, rateLimiterMock.Object); + + var context = new DefaultHttpContext(); + context.Request.Path = "/test/endpoint"; + + // Act + await middleware.InvokeAsync(context); + + // Assert + Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode); + } + + [Fact] + public async Task InvokeAsync_ShouldReturn429_WhenRateLimitIsExceeded() + { + // Arrange + var rateLimiterMock = new Mock(); + rateLimiterMock + .Setup(r => r.IsRequestAllowed(It.IsAny(), It.IsAny())) + .Returns(false); + + var middleware = new RateLimiterMiddleware(async (context) => { await Task.CompletedTask; }, rateLimiterMock.Object); + + var context = new DefaultHttpContext(); + context.Request.Path = "/test/endpoint"; + context.Request.Headers["X-Client-Token"] = "client-token"; + + // Act + await middleware.InvokeAsync(context); + + // Assert + Assert.Equal(StatusCodes.Status429TooManyRequests, context.Response.StatusCode); + } + + [Fact] + public async Task InvokeAsync_ShouldCallNextMiddleware_WhenRequestIsAllowed() + { + // Arrange + var rateLimiterMock = new Mock(); + rateLimiterMock + .Setup(r => r.IsRequestAllowed(It.IsAny(), It.IsAny())) + .Returns(true); + + var nextMiddlewareCalled = false; + var middleware = new RateLimiterMiddleware(async (context) => { nextMiddlewareCalled = true; await Task.CompletedTask; }, rateLimiterMock.Object); + + var context = new DefaultHttpContext(); + context.Request.Path = "/test/endpoint"; + context.Request.Headers["X-Client-Token"] = "client-token"; + + // Act + await middleware.InvokeAsync(context); + + // Assert + Assert.True(nextMiddlewareCalled); + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + } +} \ No newline at end of file diff --git a/RateLimiter.Tests/RateLimiter.Tests.csproj b/RateLimiter.Tests/RateLimiter.Tests.csproj index 5cbfc4e8..0d9e675f 100644 --- a/RateLimiter.Tests/RateLimiter.Tests.csproj +++ b/RateLimiter.Tests/RateLimiter.Tests.csproj @@ -8,8 +8,15 @@ + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + \ No newline at end of file diff --git a/RateLimiter.Tests/Rules/RequestPerTimeSpanRuleTests.cs b/RateLimiter.Tests/Rules/RequestPerTimeSpanRuleTests.cs new file mode 100644 index 00000000..29e598f6 --- /dev/null +++ b/RateLimiter.Tests/Rules/RequestPerTimeSpanRuleTests.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using Xunit; +using RateLimiter.Rules; + +namespace RateLimiter.Tests.Rules; + +public class RequestPerTimeSpanRuleTests +{ + [Fact] + public void IsRequestAllowed_ShouldReturnTrue_WhenRequestLogsAreEmpty() + { + // Arrange + var rule = new RequestPerTimeSpanRule(5, TimeSpan.FromMinutes(1)); + var requestLogs = new List(); + var timeOfRequest = DateTime.UtcNow; + + // Act + bool result = rule.IsRequestAllowed(requestLogs, timeOfRequest); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsRequestAllowed_ShouldReturnTrue_WhenRequestCountIsBelowLimit() + { + // Arrange + var rule = new RequestPerTimeSpanRule(5, TimeSpan.FromMinutes(1)); + var requestLogs = new List + { + DateTime.UtcNow.AddSeconds(-10), + DateTime.UtcNow.AddSeconds(-20) + }; + var timeOfRequest = DateTime.UtcNow; + + // Act + bool result = rule.IsRequestAllowed(requestLogs, timeOfRequest); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsRequestAllowed_ShouldReturnFalse_WhenRequestCountExceedsLimit() + { + // Arrange + var rule = new RequestPerTimeSpanRule(3, TimeSpan.FromMinutes(1)); + var requestLogs = new List + { + DateTime.UtcNow.AddSeconds(-10), + DateTime.UtcNow.AddSeconds(-20), + DateTime.UtcNow.AddSeconds(-30) + }; + var timeOfRequest = DateTime.UtcNow; + + // Act + bool result = rule.IsRequestAllowed(requestLogs, timeOfRequest); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsRequestAllowed_ShouldReturnTrue_WhenRequestCountIsBelowLimitWithOutdatedRequests() + { + // Arrange + var rule = new RequestPerTimeSpanRule(3, TimeSpan.FromSeconds(15)); + var requestLogs = new List + { + DateTime.UtcNow.AddSeconds(-20), + DateTime.UtcNow.AddSeconds(-10), + DateTime.UtcNow.AddSeconds(-5) + }; + var timeOfRequest = DateTime.UtcNow; + + // Act + bool result = rule.IsRequestAllowed(requestLogs, timeOfRequest); + + // Assert + Assert.True(result); + Assert.Equal(2, requestLogs.Count); + } +} \ No newline at end of file diff --git a/RateLimiter.Tests/Rules/TimeSpanSinceLastRequestRuleTests.cs b/RateLimiter.Tests/Rules/TimeSpanSinceLastRequestRuleTests.cs new file mode 100644 index 00000000..d601b44d --- /dev/null +++ b/RateLimiter.Tests/Rules/TimeSpanSinceLastRequestRuleTests.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using Xunit; +using RateLimiter.Rules; + +namespace RateLimiter.Tests.Rules; + +public class TimeSpanSinceLastRequestRuleTests +{ + [Fact] + public void IsRequestAllowed_ShouldReturnTrue_WhenRequestLogsAreEmpty() + { + // Arrange + var rule = new TimeSpanSinceLastRequestRule(TimeSpan.FromSeconds(10)); + var requestLogs = new List(); + var timeOfRequest = DateTime.UtcNow; + + // Act + bool result = rule.IsRequestAllowed(requestLogs, timeOfRequest); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsRequestAllowed_ShouldReturnTrue_WhenTimeSinceLastRequestExceedsLimit() + { + // Arrange + var rule = new TimeSpanSinceLastRequestRule(TimeSpan.FromSeconds(10)); + var requestLogs = new List + { + DateTime.UtcNow.AddSeconds(-15) + }; + var timeOfRequest = DateTime.UtcNow; + + // Act + bool result = rule.IsRequestAllowed(requestLogs, timeOfRequest); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsRequestAllowed_ShouldReturnFalse_WhenTimeSinceLastRequestIsBelowLimit() + { + // Arrange + var rule = new TimeSpanSinceLastRequestRule(TimeSpan.FromSeconds(10)); + var requestLogs = new List + { + DateTime.UtcNow.AddSeconds(-5) + }; + var timeOfRequest = DateTime.UtcNow; + + // Act + bool result = rule.IsRequestAllowed(requestLogs, timeOfRequest); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsRequestAllowed_ShouldReturnTrue_WhenLastRequestExceedsLimitWithMultipleOlderRequestsInLogs() + { + // Arrange + var rule = new TimeSpanSinceLastRequestRule(TimeSpan.FromSeconds(10)); + var requestLogs = new List + { + DateTime.UtcNow.AddSeconds(-30), + DateTime.UtcNow.AddSeconds(-25), + DateTime.UtcNow.AddSeconds(-20) + }; + var timeOfRequest = DateTime.UtcNow; + + // Act + bool result = rule.IsRequestAllowed(requestLogs, timeOfRequest); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsRequestAllowed_ShouldReturnFalse_WhenLastRequestIsBelowLimitWithMultipleRequestsInLogs() + { + // Arrange + var rule = new TimeSpanSinceLastRequestRule(TimeSpan.FromSeconds(10)); + var requestLogs = new List + { + DateTime.UtcNow.AddSeconds(-20), + DateTime.UtcNow.AddSeconds(-15), + DateTime.UtcNow.AddSeconds(-5) + }; + var timeOfRequest = DateTime.UtcNow; + + // Act + bool result = rule.IsRequestAllowed(requestLogs, timeOfRequest); + + // Assert + Assert.False(result); + } +} \ No newline at end of file diff --git a/RateLimiter.Tests/Services/RateLimiterServiceTests.cs b/RateLimiter.Tests/Services/RateLimiterServiceTests.cs new file mode 100644 index 00000000..f6cd2efc --- /dev/null +++ b/RateLimiter.Tests/Services/RateLimiterServiceTests.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using Xunit; +using RateLimiter.Rules; +using RateLimiter.Services; + +namespace RateLimiter.Tests.Services; + +public class RateLimiterServiceTests +{ + [Fact] + public void IsRequestAllowed_ShouldReturnTrue_WhenNoRulesExist() + { + // Arrange + var rules = new Dictionary>(); + var service = new RateLimiterService(rules); + + // Act + bool result = service.IsRequestAllowed("/test/endpoint", "client-token"); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsRequestAllowed_ShouldReturnTrue_WhenNoRulesExistForEndpoint() + { + // Arrange + var rules = new Dictionary> + { + { + "/test/endpoint", + new List + { + new RequestPerTimeSpanRule(1, TimeSpan.FromMinutes(1)) + } + } + }; + var service = new RateLimiterService(rules); + + // Act + bool result = service.IsRequestAllowed("/test/endpoint-one", "client-token"); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsRequestAllowed_ShouldReturnFalse_WhenRuleBlocksRequest() + { + // Arrange + var rules = new Dictionary> + { + { + "/test/endpoint", + new List + { + new RequestPerTimeSpanRule(1, TimeSpan.FromMinutes(1)) + } + } + }; + var service = new RateLimiterService(rules); + + // Act + bool firstRequest = service.IsRequestAllowed("/test/endpoint", "client-token"); + bool secondRequest = service.IsRequestAllowed("/test/endpoint", "client-token"); + + // Assert + Assert.True(firstRequest); + Assert.False(secondRequest); + } + + [Fact] + public void IsRequestAllowed_ShouldReturnTrue_WhenRequestsAreFromDifferentClients() + { + // Arrange + var rules = new Dictionary> + { + { + "/test/endpoint", + new List + { + new RequestPerTimeSpanRule(1, TimeSpan.FromMinutes(1)) + } + } + }; + var service = new RateLimiterService(rules); + + // Act + bool token1Request = service.IsRequestAllowed("/test/endpoint", "client-token-1"); + bool token2Request = service.IsRequestAllowed("/test/endpoint", "client-token-2"); + + // Assert + Assert.True(token1Request); + Assert.True(token2Request); + } +} \ No newline at end of file diff --git a/RateLimiter/Middleware/RateLimiterExtensions.cs b/RateLimiter/Middleware/RateLimiterExtensions.cs new file mode 100644 index 00000000..4b702c50 --- /dev/null +++ b/RateLimiter/Middleware/RateLimiterExtensions.cs @@ -0,0 +1,41 @@ +using System; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using RateLimiter.Models; +using RateLimiter.Services; + +namespace RateLimiter.Middleware; + +/// +/// Provides extension methods for configuring and using the rate limiter middleware. +/// +public static class RateLimiterExtensions +{ + /// + /// Adds the rate limiter service to the dependency injection container. + /// + /// The service collection to add the rate limiter to. + /// A configuration action to set up rate limiter options. + /// The updated service collection. + public static IServiceCollection AddRateLimiter(this IServiceCollection services, Action options) + { + RateLimiterOptions rateLimiterOptions = new(); + options(rateLimiterOptions); + + services.AddSingleton(provider => new RateLimiterService(rateLimiterOptions.Rules)); + return services; + } + + /// + /// Adds the rate limiter middleware to the application's request pipeline. + /// + /// The application builder to configure the middleware. + /// The updated application builder. + public static IApplicationBuilder UseRateLimiter(this IApplicationBuilder app) + { + var options = app.ApplicationServices.GetRequiredService(); + var rateLimiter = app.ApplicationServices.GetRequiredService(); + + return app.UseMiddleware(rateLimiter, options); + } +} \ No newline at end of file diff --git a/RateLimiter/Middleware/RateLimiterMiddleware.cs b/RateLimiter/Middleware/RateLimiterMiddleware.cs new file mode 100644 index 00000000..9f364d5e --- /dev/null +++ b/RateLimiter/Middleware/RateLimiterMiddleware.cs @@ -0,0 +1,51 @@ +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using RateLimiter.Services; + +namespace RateLimiter.Middleware; + +/// +/// Middleware for rate limiting requests based on predefined rules. +/// +public class RateLimiterMiddleware +{ + private readonly RequestDelegate _next; + private readonly IRateLimiterService _rateLimiter; + + /// + /// Initializes a new instance of the class. + /// + /// The next middleware in the pipeline. + /// The rate limiter service. + public RateLimiterMiddleware(RequestDelegate next, IRateLimiterService rateLimiter) + { + _next = next; + _rateLimiter = rateLimiter; + } + + /// + /// Invokes the middleware to check the rate limit for the current request. + /// + /// The HTTP context of the current request. + /// A task that represents the completion of request processing. + public async Task InvokeAsync(HttpContext context) + { + string resource = context.Request.Path.ToString(); + string token = context.Request.Headers["X-Client-Token"].FirstOrDefault() ?? string.Empty; + + if (string.IsNullOrEmpty(token)) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + else if (!_rateLimiter.IsRequestAllowed(resource, token)) + { + context.Response.StatusCode = StatusCodes.Status429TooManyRequests; + await context.Response.WriteAsync("Request limit exceeded."); + return; + } + + await _next(context); + } +} \ No newline at end of file diff --git a/RateLimiter/Models/RateLimiterOptions.cs b/RateLimiter/Models/RateLimiterOptions.cs new file mode 100644 index 00000000..436f67c9 --- /dev/null +++ b/RateLimiter/Models/RateLimiterOptions.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using RateLimiter.Rules; + +namespace RateLimiter.Models; + +/// +/// Represents the configuration options for the rate limiter. +/// +public class RateLimiterOptions +{ + /// + /// Gets or sets the rate limit rules for each endpoint. + /// + /// + /// The dictionary key represents the endpoint, and the value is a list of rate limit rules that apply to that endpoint. + /// + public Dictionary> Rules { get; set; } = new(); +} \ No newline at end of file diff --git a/RateLimiter/RateLimiter.csproj b/RateLimiter/RateLimiter.csproj index 19962f52..bcd26f24 100644 --- a/RateLimiter/RateLimiter.csproj +++ b/RateLimiter/RateLimiter.csproj @@ -4,4 +4,8 @@ latest enable + + + + \ No newline at end of file diff --git a/RateLimiter/Rules/IRateLimitRule.cs b/RateLimiter/Rules/IRateLimitRule.cs new file mode 100644 index 00000000..cf42d0b7 --- /dev/null +++ b/RateLimiter/Rules/IRateLimitRule.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; + +namespace RateLimiter.Rules; + +/// +/// Defines a rule for rate limiting requests. +/// +public interface IRateLimitRule +{ + /// + /// Determines whether a request is allowed based on the provided timestamps and request time. + /// + /// A list of timestamps representing previous requests. + /// The time of the current request. + /// True if the request is allowed; otherwise, false. + bool IsRequestAllowed(List requestLogs, DateTime timeOfRequest); +} \ No newline at end of file diff --git a/RateLimiter/Rules/RequestPerTimeSpanRule.cs b/RateLimiter/Rules/RequestPerTimeSpanRule.cs new file mode 100644 index 00000000..dc3bf535 --- /dev/null +++ b/RateLimiter/Rules/RequestPerTimeSpanRule.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; + +namespace RateLimiter.Rules; + +/// +/// A rule that limits the number of requests within a specified time window. +/// +public class RequestPerTimeSpanRule : IRateLimitRule +{ + private readonly int _requestLimit; + private readonly TimeSpan _window; + + /// + /// Initializes a new instance of the class. + /// + /// The maximum number of requests allowed within the time window. + /// The time window for the rate limit. + public RequestPerTimeSpanRule(int requestLimit, TimeSpan window) + { + _requestLimit = requestLimit; + _window = window; + } + + /// + /// Determines whether a request is allowed based on the provided timestamps and request time. + /// + /// A list of timestamps representing previous requests. + /// The time of the current request. + /// True if the request is allowed; otherwise, false. + public bool IsRequestAllowed(List requestLogs, DateTime timeOfRequest) + { + if (requestLogs == null || requestLogs.Count == 0) + { + return true; + } + + lock (requestLogs) + { + requestLogs.RemoveAll(timestamp => timestamp < timeOfRequest - _window); + + return requestLogs.Count < _requestLimit; + } + } +} \ No newline at end of file diff --git a/RateLimiter/Rules/TimeSpanSinceLastRequestRule.cs b/RateLimiter/Rules/TimeSpanSinceLastRequestRule.cs new file mode 100644 index 00000000..c92fbdcd --- /dev/null +++ b/RateLimiter/Rules/TimeSpanSinceLastRequestRule.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace RateLimiter.Rules; + +/// +/// A rule that enforces a minimum time span between consecutive requests. +/// +public class TimeSpanSinceLastRequestRule : IRateLimitRule +{ + private readonly TimeSpan _timeBetweenRequests; + + /// + /// Initializes a new instance of the class. + /// + /// The minimum time span required between consecutive requests. + public TimeSpanSinceLastRequestRule(TimeSpan timeBetweenRequests) + { + _timeBetweenRequests = timeBetweenRequests; + } + + /// + /// Determines whether a request is allowed based on the provided timestamps and request time. + /// + /// A list of timestamps representing previous requests. + /// The time of the current request. + /// True if the request is allowed; otherwise, false. + public bool IsRequestAllowed(List requestLogs, DateTime timeOfRequest) + { + if (requestLogs == null || requestLogs.Count == 0) + { + return true; + } + + DateTime lastRequest = requestLogs.Max(); + + return timeOfRequest - lastRequest > _timeBetweenRequests; + } +} \ No newline at end of file diff --git a/RateLimiter/Services/IRateLimiterService.cs b/RateLimiter/Services/IRateLimiterService.cs new file mode 100644 index 00000000..b0604ca9 --- /dev/null +++ b/RateLimiter/Services/IRateLimiterService.cs @@ -0,0 +1,12 @@ +namespace RateLimiter.Services; + +public interface IRateLimiterService +{ + /// + /// Determines whether a request is allowed based on the endpoint and token. + /// + /// The endpoint being accessed. + /// The token representing the client making the request. + /// True if the request is allowed; otherwise, false. + bool IsRequestAllowed(string endpoint, string token); +} \ No newline at end of file diff --git a/RateLimiter/Services/RateLimiterService.cs b/RateLimiter/Services/RateLimiterService.cs new file mode 100644 index 00000000..3878ddcb --- /dev/null +++ b/RateLimiter/Services/RateLimiterService.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using RateLimiter.Rules; + +namespace RateLimiter.Services; + +/// +/// Service for rate limiting requests based on predefined rules. +/// +public class RateLimiterService : IRateLimiterService +{ + private readonly ConcurrentDictionary> _requestLogs = new(); + private readonly Dictionary> _rules = new(); + + /// + /// Initializes a new instance of the class. + /// + /// A dictionary where the key is the endpoint, and the value is a list of rate limit rules that apply to that endpoint. + /// Thrown when the parameter is null. + public RateLimiterService(Dictionary> rules) + { + _rules = rules ?? throw new ArgumentNullException(nameof(rules)); + } + + /// + /// Determines whether a request is allowed based on the endpoint and token. + /// + /// The endpoint being accessed. + /// The token representing the client making the request. + /// True if the request is allowed; otherwise, false. + public bool IsRequestAllowed(string endpoint, string token) + { + List rules = new(); + if (!_rules.TryGetValue(endpoint, out rules)) + { + return true; + } + + if (rules.Count == 0) + { + return true; + } + + string key = $"{endpoint}:{token}"; + DateTime now = DateTime.UtcNow; + List logs = _requestLogs.GetOrAdd(key, _ => new List()); + + bool isAllowed; + lock (logs) + { + isAllowed = rules.All(rule => rule.IsRequestAllowed(logs, now)); + if (isAllowed) + { + logs.Add(now); + } + } + + return isAllowed; + } +} \ No newline at end of file