From a14bb69d6a5d519b729879715269d768e10e39fc Mon Sep 17 00:00:00 2001 From: Chris R Date: Fri, 22 May 2015 12:53:34 -0700 Subject: [PATCH] Rough outline of middleware, sample, and test projects. --- .gitignore | 3 +- ResponseCaching.sln | 50 ++++++ global.json | 3 + .../ResponseCachingSample.xproj | 19 +++ samples/ResponseCachingSample/Startup.cs | 33 ++++ samples/ResponseCachingSample/project.json | 33 ++++ .../CachingContext.cs | 143 ++++++++++++++++++ .../Microsoft.AspNet.ResponseCaching.xproj | 20 +++ .../Properties/AssemblyInfo.cs | 8 + .../ResponseCacheEntry.cs | 14 ++ .../ResponseCachingExtensions.cs | 15 ++ .../ResponseCachingMiddleware.cs | 61 ++++++++ .../project.json | 15 ++ .../CachingContextTests.cs | 33 ++++ ...crosoft.AspNet.ResponseCaching.Tests.xproj | 20 +++ .../project.json | 15 ++ 16 files changed, 484 insertions(+), 1 deletion(-) create mode 100644 ResponseCaching.sln create mode 100644 global.json create mode 100644 samples/ResponseCachingSample/ResponseCachingSample.xproj create mode 100644 samples/ResponseCachingSample/Startup.cs create mode 100644 samples/ResponseCachingSample/project.json create mode 100644 src/Microsoft.AspNet.ResponseCaching/CachingContext.cs create mode 100644 src/Microsoft.AspNet.ResponseCaching/Microsoft.AspNet.ResponseCaching.xproj create mode 100644 src/Microsoft.AspNet.ResponseCaching/Properties/AssemblyInfo.cs create mode 100644 src/Microsoft.AspNet.ResponseCaching/ResponseCacheEntry.cs create mode 100644 src/Microsoft.AspNet.ResponseCaching/ResponseCachingExtensions.cs create mode 100644 src/Microsoft.AspNet.ResponseCaching/ResponseCachingMiddleware.cs create mode 100644 src/Microsoft.AspNet.ResponseCaching/project.json create mode 100644 test/Microsoft.AspNet.ResponseCaching.Tests/CachingContextTests.cs create mode 100644 test/Microsoft.AspNet.ResponseCaching.Tests/Microsoft.AspNet.ResponseCaching.Tests.xproj create mode 100644 test/Microsoft.AspNet.ResponseCaching.Tests/project.json diff --git a/.gitignore b/.gitignore index 216e8d9c58..be311a1f7d 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,5 @@ nuget.exe *DS_Store *.ncrunchsolution *.*sdf -*.ipch \ No newline at end of file +*.ipch +project.lock.json \ No newline at end of file diff --git a/ResponseCaching.sln b/ResponseCaching.sln new file mode 100644 index 0000000000..358a798cbb --- /dev/null +++ b/ResponseCaching.sln @@ -0,0 +1,50 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 14 +VisualStudioVersion = 14.0.22823.1 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.AspNet.ResponseCaching", "src\Microsoft.AspNet.ResponseCaching\Microsoft.AspNet.ResponseCaching.xproj", "{D1031270-DBD3-4F02-A3DC-3E7DADE8EBE6}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{367AABAF-E03C-4491-A9A7-BDDE8903D1B4}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{C51DF5BD-B53D-4795-BC01-A9AB066BF286}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{89A50974-E9D4-4F87-ACF2-6A6005E64931}" +EndProject +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "ResponseCachingSample", "samples\ResponseCachingSample\ResponseCachingSample.xproj", "{1139BDEE-FA15-474D-8855-0AB91F23CF26}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{F787A492-C2FF-4569-A663-F8F24B900657}" + ProjectSection(SolutionItems) = preProject + global.json = global.json + EndProjectSection +EndProject +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.AspNet.ResponseCaching.Tests", "test\Microsoft.AspNet.ResponseCaching.Tests\Microsoft.AspNet.ResponseCaching.Tests.xproj", "{151B2027-3936-44B9-A4A0-E1E5902125AB}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {D1031270-DBD3-4F02-A3DC-3E7DADE8EBE6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D1031270-DBD3-4F02-A3DC-3E7DADE8EBE6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D1031270-DBD3-4F02-A3DC-3E7DADE8EBE6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D1031270-DBD3-4F02-A3DC-3E7DADE8EBE6}.Release|Any CPU.Build.0 = Release|Any CPU + {1139BDEE-FA15-474D-8855-0AB91F23CF26}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1139BDEE-FA15-474D-8855-0AB91F23CF26}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1139BDEE-FA15-474D-8855-0AB91F23CF26}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1139BDEE-FA15-474D-8855-0AB91F23CF26}.Release|Any CPU.Build.0 = Release|Any CPU + {151B2027-3936-44B9-A4A0-E1E5902125AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {151B2027-3936-44B9-A4A0-E1E5902125AB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {151B2027-3936-44B9-A4A0-E1E5902125AB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {151B2027-3936-44B9-A4A0-E1E5902125AB}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {D1031270-DBD3-4F02-A3DC-3E7DADE8EBE6} = {367AABAF-E03C-4491-A9A7-BDDE8903D1B4} + {1139BDEE-FA15-474D-8855-0AB91F23CF26} = {C51DF5BD-B53D-4795-BC01-A9AB066BF286} + {151B2027-3936-44B9-A4A0-E1E5902125AB} = {89A50974-E9D4-4F87-ACF2-6A6005E64931} + EndGlobalSection +EndGlobal diff --git a/global.json b/global.json new file mode 100644 index 0000000000..397ac5fc98 --- /dev/null +++ b/global.json @@ -0,0 +1,3 @@ +{ + "projects": ["src"] +} \ No newline at end of file diff --git a/samples/ResponseCachingSample/ResponseCachingSample.xproj b/samples/ResponseCachingSample/ResponseCachingSample.xproj new file mode 100644 index 0000000000..2403e53763 --- /dev/null +++ b/samples/ResponseCachingSample/ResponseCachingSample.xproj @@ -0,0 +1,19 @@ + + + + 14.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + 1139bdee-fa15-474d-8855-0ab91f23cf26 + ResponseCachingSample + ..\..\artifacts\obj\$(MSBuildProjectName) + ..\..\artifacts\bin\$(MSBuildProjectName)\ + + + 2.0 + 2931 + + + \ No newline at end of file diff --git a/samples/ResponseCachingSample/Startup.cs b/samples/ResponseCachingSample/Startup.cs new file mode 100644 index 0000000000..1ca6d9ac4c --- /dev/null +++ b/samples/ResponseCachingSample/Startup.cs @@ -0,0 +1,33 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNet.Builder; +using Microsoft.AspNet.Http; +using Microsoft.Framework.DependencyInjection; +using Microsoft.Net.Http.Headers; + +namespace ResponseCachingSample +{ + public class Startup + { + public void ConfigureServices(IServiceCollection services) + { + services.AddCaching(); + } + + public void Configure(IApplicationBuilder app) + { + app.UseResponseCaching(); + app.Run(async (context) => + { + context.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() + { + Public = true, + MaxAge = TimeSpan.FromSeconds(10) + }; + await context.Response.WriteAsync("Hello World! " + DateTime.UtcNow); + }); + } + } +} diff --git a/samples/ResponseCachingSample/project.json b/samples/ResponseCachingSample/project.json new file mode 100644 index 0000000000..ca19e78857 --- /dev/null +++ b/samples/ResponseCachingSample/project.json @@ -0,0 +1,33 @@ +{ + "webroot": "wwwroot", + "version": "1.0.0-*", + + "dependencies": { + "Microsoft.AspNet.ResponseCaching": "1.0.0-*", + "Microsoft.AspNet.Server.IIS": "1.0.0-*", + "Microsoft.AspNet.Server.WebListener": "1.0.0-*", + "Microsoft.Framework.Caching.Memory": "1.0.0-*" + }, + + "commands": { + "web": "Microsoft.AspNet.Hosting --server Microsoft.AspNet.Server.WebListener --server.urls http://localhost:5000" + }, + + "frameworks": { + "dnx451": { }, + "dnxcore50": { } + }, + + "publishExclude": [ + "node_modules", + "bower_components", + "**.xproj", + "**.user", + "**.vspscc" + ], + "exclude": [ + "wwwroot", + "node_modules", + "bower_components" + ] +} diff --git a/src/Microsoft.AspNet.ResponseCaching/CachingContext.cs b/src/Microsoft.AspNet.ResponseCaching/CachingContext.cs new file mode 100644 index 0000000000..c5586ce04e --- /dev/null +++ b/src/Microsoft.AspNet.ResponseCaching/CachingContext.cs @@ -0,0 +1,143 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNet.Http; +using Microsoft.Framework.Caching.Memory; + +namespace Microsoft.AspNet.ResponseCaching +{ + internal class CachingContext + { + private string _cacheKey; + + public CachingContext(HttpContext httpContext, IMemoryCache cache) + { + HttpContext = httpContext; + Cache = cache; + } + + private HttpContext HttpContext { get; } + + private IMemoryCache Cache { get; } + + private Stream OriginalResponseStream { get; set; } + + private MemoryStream Buffer { get; set; } + + internal bool ResponseStarted { get; set; } + + private bool CacheResponse { get; set; } + + internal bool CheckRequestAllowsCaching() + { + // Verify the method + // TODO: What other methods should be supported? + if (!string.Equals("GET", HttpContext.Request.Method, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // Verify the request headers do not opt-out of caching + // TODO: + return true; + } + + // Only QueryString is treated as case sensitive + // GET;HTTP://MYDOMAIN.COM:80/PATHBASE/PATH?QueryString + private string CreateCacheKey() + { + var request = HttpContext.Request; + return request.Method.ToUpperInvariant() + + ";" + + request.Scheme.ToUpperInvariant() + + "://" + + request.Host.Value.ToUpperInvariant() + + request.PathBase.Value.ToUpperInvariant() + + request.Path.Value.ToUpperInvariant() + + request.QueryString; + } + + internal async Task TryServeFromCacheAsync() + { + _cacheKey = CreateCacheKey(); + ResponseCacheEntry cacheEntry; + if (Cache.TryGetValue(_cacheKey, out cacheEntry)) + { + // TODO: Compare cached request headers + + // TODO: Evaluate Vary-By and select the most appropriate response + + // TODO: Content negotiation if there are multiple cached response formats? + + // TODO: Verify content freshness, or else re-validate the data? + + var response = HttpContext.Response; + // Copy the cached status code and response headers + response.StatusCode = cacheEntry.StatusCode; + foreach (var pair in cacheEntry.Headers) + { + response.Headers.SetValues(pair.Key, pair.Value); + } + + // TODO: Update cache headers (Age) + response.Headers["Served_From_Cache"] = DateTime.Now.ToString(); + + // Copy the cached response body + var body = cacheEntry.Body; + if (body.Length > 0) + { + await response.Body.WriteAsync(body, 0, body.Length); + } + return true; + } + + return false; + } + + internal void HookResponseStream() + { + // TODO: Use a wrapper stream to listen for writes (e.g. the start of the response), + // check the headers, and verify if we should cache the response. + // Then we should stream data out to the client at the same time as we buffer for the cache. + // For now we'll just buffer everything in memory before checking the response headers. + // TODO: Consider caching large responses on disk and serving them from there. + OriginalResponseStream = HttpContext.Response.Body; + Buffer = new MemoryStream(); + HttpContext.Response.Body = Buffer; + } + + internal bool OnResponseStarting() + { + // Evaluate the response headers, see if we should buffer and cache + CacheResponse = true; // TODO: + return CacheResponse; + } + + internal void FinalizeCaching() + { + if (CacheResponse) + { + // Store the buffer to cache + var cacheEntry = new ResponseCacheEntry(); + cacheEntry.StatusCode = HttpContext.Response.StatusCode; + cacheEntry.Headers = HttpContext.Response.Headers.ToList(); + cacheEntry.Body = Buffer.ToArray(); + Cache.Set(_cacheKey, cacheEntry); // TODO: Timeouts + } + + // TODO: TEMP, flush the buffer to the client + Buffer.Seek(0, SeekOrigin.Begin); + Buffer.CopyTo(OriginalResponseStream); + } + + internal void UnhookResponseStream() + { + // Unhook the response stream. + HttpContext.Response.Body = OriginalResponseStream; + } + } +} diff --git a/src/Microsoft.AspNet.ResponseCaching/Microsoft.AspNet.ResponseCaching.xproj b/src/Microsoft.AspNet.ResponseCaching/Microsoft.AspNet.ResponseCaching.xproj new file mode 100644 index 0000000000..bb65aad14e --- /dev/null +++ b/src/Microsoft.AspNet.ResponseCaching/Microsoft.AspNet.ResponseCaching.xproj @@ -0,0 +1,20 @@ + + + + 14.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + + d1031270-dbd3-4f02-a3dc-3e7dade8ebe6 + Microsoft.AspNet.ResponseCaching + ..\artifacts\obj\$(MSBuildProjectName) + ..\artifacts\bin\$(MSBuildProjectName)\ + + + + 2.0 + + + diff --git a/src/Microsoft.AspNet.ResponseCaching/Properties/AssemblyInfo.cs b/src/Microsoft.AspNet.ResponseCaching/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..09bbb58941 --- /dev/null +++ b/src/Microsoft.AspNet.ResponseCaching/Properties/AssemblyInfo.cs @@ -0,0 +1,8 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Reflection; +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.AspNet.ResponseCaching.Tests")] +[assembly: AssemblyMetadata("Serviceable", "True")] \ No newline at end of file diff --git a/src/Microsoft.AspNet.ResponseCaching/ResponseCacheEntry.cs b/src/Microsoft.AspNet.ResponseCaching/ResponseCacheEntry.cs new file mode 100644 index 0000000000..f23685de33 --- /dev/null +++ b/src/Microsoft.AspNet.ResponseCaching/ResponseCacheEntry.cs @@ -0,0 +1,14 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace Microsoft.AspNet.ResponseCaching +{ + internal class ResponseCacheEntry + { + public int StatusCode { get; set; } + internal IEnumerable> Headers { get; set; } + internal byte[] Body { get; set; } + } +} diff --git a/src/Microsoft.AspNet.ResponseCaching/ResponseCachingExtensions.cs b/src/Microsoft.AspNet.ResponseCaching/ResponseCachingExtensions.cs new file mode 100644 index 0000000000..6632ad0d2b --- /dev/null +++ b/src/Microsoft.AspNet.ResponseCaching/ResponseCachingExtensions.cs @@ -0,0 +1,15 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNet.ResponseCaching; + +namespace Microsoft.AspNet.Builder +{ + public static class ResponseCachingExtensions + { + public static IApplicationBuilder UseResponseCaching(this IApplicationBuilder app) + { + return app.UseMiddleware(); + } + } +} diff --git a/src/Microsoft.AspNet.ResponseCaching/ResponseCachingMiddleware.cs b/src/Microsoft.AspNet.ResponseCaching/ResponseCachingMiddleware.cs new file mode 100644 index 0000000000..0c0fda42a2 --- /dev/null +++ b/src/Microsoft.AspNet.ResponseCaching/ResponseCachingMiddleware.cs @@ -0,0 +1,61 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading.Tasks; +using Microsoft.AspNet.Builder; +using Microsoft.AspNet.Http; +using Microsoft.Framework.Caching.Memory; + +namespace Microsoft.AspNet.ResponseCaching +{ + // http://tools.ietf.org/html/rfc7234 + public class ResponseCachingMiddleware + { + private readonly RequestDelegate _next; + private readonly IMemoryCache _cache; + + public ResponseCachingMiddleware(RequestDelegate next, IMemoryCache cache) + { + _next = next; + _cache = cache; + } + + public async Task Invoke(HttpContext context) + { + var cachingContext = new CachingContext(context, _cache); + // Should we attempt any caching logic? + if (cachingContext.CheckRequestAllowsCaching()) + { + // Can this request be served from cache? + if (await cachingContext.TryServeFromCacheAsync()) + { + return; + } + + // Hook up to listen to the response stream + cachingContext.HookResponseStream(); + + try + { + await _next(context); + + // If there was no response body, check the response headers now. We can cache things like redirects. + if (!cachingContext.ResponseStarted) + { + cachingContext.OnResponseStarting(); + } + // Finalize the cache entry + cachingContext.FinalizeCaching(); + } + finally + { + cachingContext.UnhookResponseStream(); + } + } + else + { + await _next(context); + } + } + } +} diff --git a/src/Microsoft.AspNet.ResponseCaching/project.json b/src/Microsoft.AspNet.ResponseCaching/project.json new file mode 100644 index 0000000000..b7f364b6cc --- /dev/null +++ b/src/Microsoft.AspNet.ResponseCaching/project.json @@ -0,0 +1,15 @@ +{ + "version": "1.0.0-*", + "description": "Middleare that automatically caches HTTP responses on the server.", + "dependencies": { + "Microsoft.AspNet.Http.Abstractions": "1.0.0-*", + "Microsoft.Framework.Caching.Abstractions": "1.0.0-*" + }, + "frameworks" : { + "dnx451": { }, + "dnxcore50" : { + "dependencies": { + } + } + } +} diff --git a/test/Microsoft.AspNet.ResponseCaching.Tests/CachingContextTests.cs b/test/Microsoft.AspNet.ResponseCaching.Tests/CachingContextTests.cs new file mode 100644 index 0000000000..d0315b5a58 --- /dev/null +++ b/test/Microsoft.AspNet.ResponseCaching.Tests/CachingContextTests.cs @@ -0,0 +1,33 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNet.Http.Internal; +using Microsoft.Framework.Caching.Memory; +using Xunit; + +namespace Microsoft.AspNet.ResponseCaching +{ + public class CachingContextTests + { + [Fact] + public void CheckRequestAllowsCaching_Method_GET_Allowed() + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.Method = "GET"; + var context = new CachingContext(httpContext, new MemoryCache(new MemoryCacheOptions())); + + Assert.True(context.CheckRequestAllowsCaching()); + } + + [Theory] + [InlineData("POST")] + public void CheckRequestAllowsCaching_Method_Unsafe_NotAllowed(string method) + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.Method = method; + var context = new CachingContext(httpContext, new MemoryCache(new MemoryCacheOptions())); + + Assert.False(context.CheckRequestAllowsCaching()); + } + } +} diff --git a/test/Microsoft.AspNet.ResponseCaching.Tests/Microsoft.AspNet.ResponseCaching.Tests.xproj b/test/Microsoft.AspNet.ResponseCaching.Tests/Microsoft.AspNet.ResponseCaching.Tests.xproj new file mode 100644 index 0000000000..ee98d4b4cd --- /dev/null +++ b/test/Microsoft.AspNet.ResponseCaching.Tests/Microsoft.AspNet.ResponseCaching.Tests.xproj @@ -0,0 +1,20 @@ + + + + 14.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + + 151b2027-3936-44b9-a4a0-e1e5902125ab + Microsoft.AspNet.ResponseCaching.Tests + ..\..\artifacts\obj\$(MSBuildProjectName) + ..\..\artifacts\bin\$(MSBuildProjectName)\ + + + + 2.0 + + + diff --git a/test/Microsoft.AspNet.ResponseCaching.Tests/project.json b/test/Microsoft.AspNet.ResponseCaching.Tests/project.json new file mode 100644 index 0000000000..5ad3ebb2d2 --- /dev/null +++ b/test/Microsoft.AspNet.ResponseCaching.Tests/project.json @@ -0,0 +1,15 @@ +{ + "dependencies": { + "Microsoft.AspNet.ResponseCaching": "1.0.0-*", + "xunit.runner.aspnet": "2.0.0-aspnet-*", + "Microsoft.AspNet.Http": "1.0.0-beta5-11528", + "Microsoft.Framework.Caching.Memory": "1.0.0-beta5-11395" + }, + "commands": { + "test": "xunit.runner.aspnet" + }, + "frameworks": { + "dnx451": { }, + "dnxcore50": { } + } +}