diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c2f0f84 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,52 @@ +*.doc diff=astextplain +*.DOC diff=astextplain +*.docx diff=astextplain +*.DOCX diff=astextplain +*.dot diff=astextplain +*.DOT diff=astextplain +*.pdf diff=astextplain +*.PDF diff=astextplain +*.rtf diff=astextplain +*.RTF diff=astextplain + +*.jpg binary +*.png binary +*.gif binary + +*.cs text=auto diff=csharp +*.vb text=auto +*.resx text=auto +*.c text=auto +*.cpp text=auto +*.cxx text=auto +*.h text=auto +*.hxx text=auto +*.py text=auto +*.rb text=auto +*.java text=auto +*.html text=auto +*.htm text=auto +*.css text=auto +*.scss text=auto +*.sass text=auto +*.less text=auto +*.js text=auto +*.lisp text=auto +*.clj text=auto +*.sql text=auto +*.php text=auto +*.lua text=auto +*.m text=auto +*.asm text=auto +*.erl text=auto +*.fs text=auto +*.fsx text=auto +*.hs text=auto + +*.csproj text=auto +*.vbproj text=auto +*.fsproj text=auto +*.dbproj text=auto +*.sln text=auto eol=crlf + +*.sh eol=lf \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6acc284 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +[Oo]bj/ +[Bb]in/ +TestResults/ +.nuget/ +*.sln.ide/ +_ReSharper.*/ +packages/ +artifacts/ +PublishProfiles/ +.vs/ +*.user +*.suo +*.cache +*.docstates +_ReSharper.* +nuget.exe +*net45.csproj +*net451.csproj +*k10.csproj +*.psess +*.vsp +*.pidb +*.userprefs +*DS_Store +*.ncrunchsolution +*.*sdf +*.ipch +project.lock.json +runtimes/ +.build/ +.testPublish/ +launchSettings.json diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..903efa7 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,23 @@ +language: csharp +sudo: required +dist: trusty +addons: + apt: + packages: + - libunwind8 +mono: + - 4.0.5 +os: + - linux + - osx +osx_image: xcode7.1 +branches: + only: + - master + - release + - dev + - /^(.*\/)?ci-.*$/ +before_install: + - if test "$TRAVIS_OS_NAME" == "osx"; then brew update; brew install openssl; brew link --force openssl; fi +script: + - ./build.sh --quiet verify \ No newline at end of file diff --git a/NuGet.Config b/NuGet.Config new file mode 100644 index 0000000..46c3b3e --- /dev/null +++ b/NuGet.Config @@ -0,0 +1,7 @@ + + + + + + + diff --git a/NuGet.master.config b/NuGet.master.config new file mode 100644 index 0000000..e2edffc --- /dev/null +++ b/NuGet.master.config @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/NuGet.release.config b/NuGet.release.config new file mode 100644 index 0000000..1978dc0 --- /dev/null +++ b/NuGet.release.config @@ -0,0 +1,7 @@ + + + + + + + diff --git a/ResponseCaching.sln b/ResponseCaching.sln new file mode 100644 index 0000000..6f455f5 --- /dev/null +++ b/ResponseCaching.sln @@ -0,0 +1,50 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 14 +VisualStudioVersion = 14.0.25123.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.AspNetCore.ResponseCaching", "src\Microsoft.AspNetCore.ResponseCaching\Microsoft.AspNetCore.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.AspNetCore.ResponseCaching.Tests", "test\Microsoft.AspNetCore.ResponseCaching.Tests\Microsoft.AspNetCore.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/appveyor.yml b/appveyor.yml new file mode 100644 index 0000000..be95b88 --- /dev/null +++ b/appveyor.yml @@ -0,0 +1,13 @@ +init: + - git config --global core.autocrlf true +branches: + only: + - master + - release + - dev + - /^(.*\/)?ci-.*$/ +build_script: + - build.cmd --quiet verify +clone_depth: 1 +test: off +deploy: off \ No newline at end of file diff --git a/build.cmd b/build.cmd new file mode 100644 index 0000000..7d4894c --- /dev/null +++ b/build.cmd @@ -0,0 +1,2 @@ +@ECHO OFF +PowerShell -NoProfile -NoLogo -ExecutionPolicy unrestricted -Command "[System.Threading.Thread]::CurrentThread.CurrentCulture = ''; [System.Threading.Thread]::CurrentThread.CurrentUICulture = '';& '%~dp0build.ps1' %*; exit $LASTEXITCODE" \ No newline at end of file diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..8f2f996 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,67 @@ +$ErrorActionPreference = "Stop" + +function DownloadWithRetry([string] $url, [string] $downloadLocation, [int] $retries) +{ + while($true) + { + try + { + Invoke-WebRequest $url -OutFile $downloadLocation + break + } + catch + { + $exceptionMessage = $_.Exception.Message + Write-Host "Failed to download '$url': $exceptionMessage" + if ($retries -gt 0) { + $retries-- + Write-Host "Waiting 10 seconds before retrying. Retries left: $retries" + Start-Sleep -Seconds 10 + + } + else + { + $exception = $_.Exception + throw $exception + } + } + } +} + +cd $PSScriptRoot + +$repoFolder = $PSScriptRoot +$env:REPO_FOLDER = $repoFolder + +$koreBuildZip="https://github.com/aspnet/KoreBuild/archive/dev.zip" +if ($env:KOREBUILD_ZIP) +{ + $koreBuildZip=$env:KOREBUILD_ZIP +} + +$buildFolder = ".build" +$buildFile="$buildFolder\KoreBuild.ps1" + +if (!(Test-Path $buildFolder)) { + Write-Host "Downloading KoreBuild from $koreBuildZip" + + $tempFolder=$env:TEMP + "\KoreBuild-" + [guid]::NewGuid() + New-Item -Path "$tempFolder" -Type directory | Out-Null + + $localZipFile="$tempFolder\korebuild.zip" + + DownloadWithRetry -url $koreBuildZip -downloadLocation $localZipFile -retries 6 + + Add-Type -AssemblyName System.IO.Compression.FileSystem + [System.IO.Compression.ZipFile]::ExtractToDirectory($localZipFile, $tempFolder) + + New-Item -Path "$buildFolder" -Type directory | Out-Null + copy-item "$tempFolder\**\build\*" $buildFolder -Recurse + + # Cleanup + if (Test-Path $tempFolder) { + Remove-Item -Recurse -Force $tempFolder + } +} + +&"$buildFile" $args \ No newline at end of file diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..f420810 --- /dev/null +++ b/build.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +repoFolder="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +cd $repoFolder + +koreBuildZip="https://github.com/aspnet/KoreBuild/archive/dev.zip" +if [ ! -z $KOREBUILD_ZIP ]; then + koreBuildZip=$KOREBUILD_ZIP +fi + +buildFolder=".build" +buildFile="$buildFolder/KoreBuild.sh" + +if test ! -d $buildFolder; then + echo "Downloading KoreBuild from $koreBuildZip" + + tempFolder="/tmp/KoreBuild-$(uuidgen)" + mkdir $tempFolder + + localZipFile="$tempFolder/korebuild.zip" + + retries=6 + until (wget -O $localZipFile $koreBuildZip 2>/dev/null || curl -o $localZipFile --location $koreBuildZip 2>/dev/null) + do + echo "Failed to download '$koreBuildZip'" + if [ "$retries" -le 0 ]; then + exit 1 + fi + retries=$((retries - 1)) + echo "Waiting 10 seconds before retrying. Retries left: $retries" + sleep 10s + done + + unzip -q -d $tempFolder $localZipFile + + mkdir $buildFolder + cp -r $tempFolder/**/build/** $buildFolder + + chmod +x $buildFile + + # Cleanup + if test ! -d $tempFolder; then + rm -rf $tempFolder + fi +fi + +$buildFile -r $repoFolder "$@" \ No newline at end of file diff --git a/global.json b/global.json new file mode 100644 index 0000000..0ddf69c --- /dev/null +++ b/global.json @@ -0,0 +1,3 @@ +{ + "projects": [ "src" ] +} \ No newline at end of file diff --git a/makefile.shade b/makefile.shade new file mode 100644 index 0000000..562494d --- /dev/null +++ b/makefile.shade @@ -0,0 +1,7 @@ + +var VERSION='0.1' +var FULL_VERSION='0.1' +var AUTHORS='Microsoft Open Technologies, Inc.' + +use-standard-lifecycle +k-standard-goals diff --git a/samples/ResponseCachingSample/ResponseCachingSample.xproj b/samples/ResponseCachingSample/ResponseCachingSample.xproj new file mode 100644 index 0000000..cec78a3 --- /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) + .\bin\ + + + 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 0000000..78685d6 --- /dev/null +++ b/samples/ResponseCachingSample/Startup.cs @@ -0,0 +1,45 @@ +// 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.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Net.Http.Headers; + +namespace ResponseCachingSample +{ + public class Startup + { + public void ConfigureServices(IServiceCollection services) + { + services.AddMemoryCache(); + } + + 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); + }); + } + + public static void Main(string[] args) + { + var host = new WebHostBuilder() + .UseIISIntegration() + .UseKestrel() + .UseStartup() + .Build(); + + host.Run(); + } + } +} diff --git a/samples/ResponseCachingSample/project.json b/samples/ResponseCachingSample/project.json new file mode 100644 index 0000000..a759eb8 --- /dev/null +++ b/samples/ResponseCachingSample/project.json @@ -0,0 +1,36 @@ +{ + "version": "0.1.0-*", + "buildOptions": { + "emitEntryPoint": true, + "preserveCompilationContext": true + }, + + "dependencies": { + "Microsoft.NETCore.Platforms": "1.0.1-*", + "Microsoft.AspNetCore.Server.IISIntegration": "1.0.0-*", + "Microsoft.AspNetCore.Server.Kestrel": "1.0.0-*", + "Microsoft.Extensions.Caching.Memory": "1.0.0-*", + "Microsoft.AspNetCore.Http.Abstractions": "1.0.0-*", + "Microsoft.AspNetCore.Http.Extensions": "1.0.0-*", + "Microsoft.AspNetCore.ResponseCaching": "0.1.0-*" + }, + + "frameworks": { + "net451": {}, + "netcoreapp1.0": { + "dependencies": { + "Microsoft.NETCore.App": { + "version": "1.0.0-*", + "type": "platform" + }, + "System.Console": "4.0.0-*" + } + } + }, + + "runtimeOptions": { + "configProperties": { + "System.GC.Server": true + } + } +} diff --git a/src/Microsoft.AspNetCore.ResponseCaching/CachingContext.cs b/src/Microsoft.AspNetCore.ResponseCaching/CachingContext.cs new file mode 100644 index 0000000..e5db372 --- /dev/null +++ b/src/Microsoft.AspNetCore.ResponseCaching/CachingContext.cs @@ -0,0 +1,210 @@ +// 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.Collections.Generic; +using System.IO; +using System.Globalization; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.ResponseCaching +{ + public struct CachingContext + { + private string _cacheKey; + private RequestType _requestType; + private bool _isProxied; + + public CachingContext(HttpContext httpContext, IMemoryCache cache) + { + HttpContext = httpContext; + Cache = cache; + + _requestType = RequestType.NotCached; + _isProxied = false; + _cacheKey = null; + OriginalResponseStream = null; + Buffer = null; + ResponseStarted = false; + CacheResponse = false; + } + + 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; } + + public bool CheckRequestAllowsCaching() + { + // Verify the method + // TODO: What other methods should be supported? + if (string.Equals("GET", HttpContext.Request.Method, StringComparison.OrdinalIgnoreCase)) + { + _requestType = RequestType.FullReponse; + } + else if (string.Equals("HEAD", HttpContext.Request.Method, StringComparison.OrdinalIgnoreCase) || + string.Equals("OPTIONS", HttpContext.Request.Method, StringComparison.OrdinalIgnoreCase)) + { + _requestType = RequestType.HeadersOnly; + } + else + { + _requestType = RequestType.NotCached; + 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; + + var headers = cacheEntry.Headers; + for (var i = 0; i < headers.Count; i++) + { + var entry = headers[i]; + response.Headers[entry.Key] = entry.Value; + } + + // TODO: Allow setting proxied _isProxied + var age = Math.Max((DateTimeOffset.UtcNow - cacheEntry.Created).TotalSeconds, 0.0); + var ageString = (age > int.MaxValue ? int.MaxValue : (int)age).ToString(CultureInfo.InvariantCulture); + response.Headers[_isProxied ? "Age" : "X-Cache-Age"] = ageString; + + if (_requestType == RequestType.HeadersOnly) + { + response.Headers["Content-Length"] = "0"; + } + else + { + // Copy the cached response body + var body = cacheEntry.Body; + response.Headers["Content-Length"] = body.Length.ToString(CultureInfo.InvariantCulture); + 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() + { + // Don't cache errors? 404 etc + if (CacheResponse && HttpContext.Response.StatusCode == 200) + { + // Store the buffer to cache + var cacheEntry = new ResponseCacheEntry() + { + Created = DateTimeOffset.UtcNow, + StatusCode = HttpContext.Response.StatusCode + }; + + var headers = HttpContext.Response.Headers; + var count = headers.Count + - (headers.ContainsKey("Date") ? 1 : 0) + - (headers.ContainsKey("Content-Length") ? 1 : 0) + - (headers.ContainsKey("Age") ? 1 : 0); + var cachedHeaders = new List>(count); + int age = 0; + foreach (var entry in headers) + { + // Reduce create date by Age + if (entry.Key == "Age" && int.TryParse(entry.Value, out age) && age > 0) + { + cacheEntry.Created -= new TimeSpan(0, 0, age); + } + // Don't copy Date header or Content-Length + else if (entry.Key != "Date" && entry.Key != "Content-Length") + { + cachedHeaders.Add(entry); + } + } + + 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; + } + + private enum RequestType + { + NotCached, + HeadersOnly, + FullReponse + } + } +} diff --git a/src/Microsoft.AspNetCore.ResponseCaching/IResponseCacheOptions.cs b/src/Microsoft.AspNetCore.ResponseCaching/IResponseCacheOptions.cs new file mode 100644 index 0000000..556c33e --- /dev/null +++ b/src/Microsoft.AspNetCore.ResponseCaching/IResponseCacheOptions.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.ResponseCaching +{ + interface IResponseCacheOptions + { + int MaxCachedItemBytes { get; set; } + } +} diff --git a/src/Microsoft.AspNetCore.ResponseCaching/Microsoft.AspNetCore.ResponseCaching.xproj b/src/Microsoft.AspNetCore.ResponseCaching/Microsoft.AspNetCore.ResponseCaching.xproj new file mode 100644 index 0000000..5fa1019 --- /dev/null +++ b/src/Microsoft.AspNetCore.ResponseCaching/Microsoft.AspNetCore.ResponseCaching.xproj @@ -0,0 +1,18 @@ + + + + 14.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + d1031270-dbd3-4f02-a3dc-3e7dade8ebe6 + Microsoft.AspNetCore.ResponseCaching + ..\artifacts\obj\$(MSBuildProjectName) + .\bin\ + + + 2.0 + + + \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.ResponseCaching/Properties/AssemblyInfo.cs b/src/Microsoft.AspNetCore.ResponseCaching/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..09bbb58 --- /dev/null +++ b/src/Microsoft.AspNetCore.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.AspNetCore.ResponseCaching/ResponseCacheEntry.cs b/src/Microsoft.AspNetCore.ResponseCaching/ResponseCacheEntry.cs new file mode 100644 index 0000000..dc749f7 --- /dev/null +++ b/src/Microsoft.AspNetCore.ResponseCaching/ResponseCacheEntry.cs @@ -0,0 +1,17 @@ +// 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; +using Microsoft.Extensions.Primitives; +using System; + +namespace Microsoft.AspNetCore.ResponseCaching +{ + public class ResponseCacheEntry + { + public int StatusCode { get; set; } + public List> Headers { get; set; } + public byte[] Body { get; set; } + public DateTimeOffset Created { get; set; } + } +} diff --git a/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingExtensions.cs b/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingExtensions.cs new file mode 100644 index 0000000..60817a6 --- /dev/null +++ b/src/Microsoft.AspNetCore.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.AspNetCore.ResponseCaching; + +namespace Microsoft.AspNetCore.Builder +{ + public static class ResponseCachingExtensions + { + public static IApplicationBuilder UseResponseCaching(this IApplicationBuilder app) + { + return app.UseMiddleware(); + } + } +} diff --git a/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingMiddleware.cs b/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingMiddleware.cs new file mode 100644 index 0000000..fb7e4ce --- /dev/null +++ b/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingMiddleware.cs @@ -0,0 +1,60 @@ +// 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.AspNetCore.Http; +using Microsoft.Extensions.Caching.Memory; + +namespace Microsoft.AspNetCore.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.AspNetCore.ResponseCaching/project.json b/src/Microsoft.AspNetCore.ResponseCaching/project.json new file mode 100644 index 0000000..76c316a --- /dev/null +++ b/src/Microsoft.AspNetCore.ResponseCaching/project.json @@ -0,0 +1,31 @@ +{ + "version": "0.1.0-*", + "description": "Middleware that automatically caches HTTP responses on the server.", + "packOptions": { + "repository": { + "type": "git", + "url": "git://github.com/aspnet/ResponseCaching" + }, + "tags": [ + "aspnetcore", + "response cache" + ] + }, + "buildOptions": { + "warningsAsErrors": true + }, + "dependencies": { + "Microsoft.AspNetCore.Http.Abstractions": "1.0.0-*", + "Microsoft.Extensions.Caching.Abstractions": "1.0.0-*", + "System.Buffers": "4.0.0-*" + }, + "frameworks": { + "net451": { + "frameworkAssemblies": { + "System.Runtime": { "type": "build" } + } + }, + "netstandard1.3": { + } + } +} diff --git a/test/Microsoft.AspNetCore.ResponseCaching.Tests/CachingContextTests.cs b/test/Microsoft.AspNetCore.ResponseCaching.Tests/CachingContextTests.cs new file mode 100644 index 0000000..d191932 --- /dev/null +++ b/test/Microsoft.AspNetCore.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.AspNetCore.Http; +using Microsoft.Extensions.Caching.Memory; +using Xunit; + +namespace Microsoft.AspNetCore.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.AspNetCore.ResponseCaching.Tests/Microsoft.AspNetCore.ResponseCaching.Tests.xproj b/test/Microsoft.AspNetCore.ResponseCaching.Tests/Microsoft.AspNetCore.ResponseCaching.Tests.xproj new file mode 100644 index 0000000..cefc297 --- /dev/null +++ b/test/Microsoft.AspNetCore.ResponseCaching.Tests/Microsoft.AspNetCore.ResponseCaching.Tests.xproj @@ -0,0 +1,21 @@ + + + + 14.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + 151b2027-3936-44b9-a4a0-e1e5902125ab + Microsoft.AspNetCore.ResponseCaching.Tests + ..\..\artifacts\obj\$(MSBuildProjectName) + .\bin\ + + + 2.0 + + + + + + \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.ResponseCaching.Tests/project.json b/test/Microsoft.AspNetCore.ResponseCaching.Tests/project.json new file mode 100644 index 0000000..0a036ec --- /dev/null +++ b/test/Microsoft.AspNetCore.ResponseCaching.Tests/project.json @@ -0,0 +1,34 @@ +{ + "buildOptions": { + "warningsAsErrors": true + }, + "dependencies": { + "dotnet-test-xunit": "1.0.0-*", + "Microsoft.AspNetCore.Http": "1.0.0-*", + "Microsoft.Extensions.Caching.Memory": "1.0.0-*", + "Microsoft.AspNetCore.ResponseCaching": "0.1.0-*", + "xunit": "2.1.0" + }, + "frameworks": { + + "netcoreapp1.0": { + "dependencies": { + "Microsoft.NETCore.App": { + "version": "1.0.0-*", + "type": "platform" + } + }, + "imports": [ + "dnxcore50", + "portable-net451+win8" + ] + }, + + "net451": { + "dependencies": { + "xunit.runner.console": "2.1.0" + } + } + }, + "testRunner": "xunit" +}