diff --git a/src/WebJobs.Script.WebHost/Controllers/FunctionsController.cs b/src/WebJobs.Script.WebHost/Controllers/FunctionsController.cs
new file mode 100755
index 0000000000..7d86d27023
--- /dev/null
+++ b/src/WebJobs.Script.WebHost/Controllers/FunctionsController.cs
@@ -0,0 +1,166 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.Extensions;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Azure.WebJobs.Script.Description;
+using Microsoft.Azure.WebJobs.Script.Management.Models;
+using Microsoft.Azure.WebJobs.Script.WebHost.Filters;
+using Microsoft.Azure.WebJobs.Script.WebHost.Management;
+using Microsoft.Azure.WebJobs.Script.WebHost.Models;
+using Microsoft.Azure.WebJobs.Script.WebHost.Security.Authorization.Policies;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.Azure.WebJobs.Script.WebHost.Controllers
+{
+ ///
+ /// Controller responsible for administrative and management operations on functions
+ /// example retriving a list of functions, invoking a function, creating a function, etc
+ ///
+ public class FunctionsController : Controller
+ {
+ private readonly IWebFunctionsManager _functionsManager;
+ private readonly ScriptHostManager _scriptHostManager;
+ private readonly ILogger _logger;
+ private static readonly Regex FunctionNameValidationRegex = new Regex(@"^[a-z][a-z0-9_\-]{0,127}$(? List()
+ {
+ return Ok(await _functionsManager.GetFunctionsMetadata(Request));
+ }
+
+ [HttpGet]
+ [Route("admin/functions/{name}")]
+ [Authorize(Policy = PolicyNames.AdminAuthLevel)]
+ public async Task Get(string name)
+ {
+ (var success, var function) = await _functionsManager.TryGetFunction(name, Request);
+
+ return success
+ ? Ok(function)
+ : NotFound() as IActionResult;
+ }
+
+ [HttpPut]
+ [Route("admin/functions/{name}")]
+ [Authorize(Policy = PolicyNames.AdminAuthLevel)]
+ public async Task CreateOrUpdate(string name, [FromBody] FunctionMetadataResponse functionMetadata)
+ {
+ if (!FunctionNameValidationRegex.IsMatch(name))
+ {
+ return BadRequest($"{name} is not a valid function name");
+ }
+
+ (var success, var configChanged, var functionMetadataResponse) = await _functionsManager.CreateOrUpdate(name, functionMetadata, Request);
+
+ if (success)
+ {
+ if (configChanged)
+ {
+ // TODO: sync triggers
+ }
+
+ return Created(Request.GetDisplayUrl(), functionMetadataResponse);
+ }
+ else
+ {
+ return StatusCode(500);
+ }
+ }
+
+ [HttpPost]
+ [Route("admin/functions/{name}")]
+ [Authorize(Policy = PolicyNames.AdminAuthLevel)]
+ [RequiresRunningHost]
+ public IActionResult Invoke(string name, [FromBody] FunctionInvocation invocation)
+ {
+ if (invocation == null)
+ {
+ return BadRequest();
+ }
+
+ FunctionDescriptor function = _scriptHostManager.Instance.GetFunctionOrNull(name);
+ if (function == null)
+ {
+ return NotFound();
+ }
+
+ ParameterDescriptor inputParameter = function.Parameters.First(p => p.IsTrigger);
+ Dictionary arguments = new Dictionary()
+ {
+ { inputParameter.Name, invocation.Input }
+ };
+ Task.Run(() => _scriptHostManager.Instance.CallAsync(function.Name, arguments));
+
+ return Accepted();
+ }
+
+ [HttpGet]
+ [Route("admin/functions/{name}/status")]
+ [Authorize(Policy = PolicyNames.AdminAuthLevel)]
+ [RequiresRunningHost]
+ public IActionResult GetFunctionStatus(string name)
+ {
+ FunctionStatus status = new FunctionStatus();
+ Collection functionErrors = null;
+
+ // first see if the function has any errors
+ if (_scriptHostManager.Instance.FunctionErrors.TryGetValue(name, out functionErrors))
+ {
+ status.Errors = functionErrors;
+ }
+ else
+ {
+ // if we don't have any errors registered, make sure the function exists
+ // before returning empty errors
+ FunctionDescriptor function = _scriptHostManager.Instance.Functions.FirstOrDefault(p => p.Name.ToLowerInvariant() == name.ToLowerInvariant());
+ if (function == null)
+ {
+ return NotFound();
+ }
+ }
+
+ return Ok(status);
+ }
+
+ [HttpDelete]
+ [Route("admin/functions/{name}")]
+ [Authorize(Policy = PolicyNames.AdminAuthLevel)]
+ public async Task Delete(string name)
+ {
+ (var found, var function) = await _functionsManager.TryGetFunction(name, Request);
+ if (!found)
+ {
+ return NotFound();
+ }
+
+ (var deleted, var error) = _functionsManager.TryDeleteFunction(function);
+
+ if (deleted)
+ {
+ return NoContent();
+ }
+ else
+ {
+ return StatusCode(StatusCodes.Status500InternalServerError, error);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/WebJobs.Script.WebHost/Controllers/AdminController.cs b/src/WebJobs.Script.WebHost/Controllers/HostController.cs
similarity index 94%
rename from src/WebJobs.Script.WebHost/Controllers/AdminController.cs
rename to src/WebJobs.Script.WebHost/Controllers/HostController.cs
index 051aebae7d..c02f20360d 100644
--- a/src/WebJobs.Script.WebHost/Controllers/AdminController.cs
+++ b/src/WebJobs.Script.WebHost/Controllers/HostController.cs
@@ -1,10 +1,13 @@
-// Copyright (c) .NET Foundation. All rights reserved.
+// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics;
+using System.IO;
+using System.IO.Compression;
using System.Linq;
+using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
@@ -14,6 +17,7 @@
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.WebApiCompatShim;
using Microsoft.Azure.WebJobs.Host;
+using Microsoft.Azure.WebJobs.Script.Config;
using Microsoft.Azure.WebJobs.Script.Description;
using Microsoft.Azure.WebJobs.Script.WebHost.Authentication;
using Microsoft.Azure.WebJobs.Script.WebHost.Filters;
@@ -25,21 +29,21 @@
namespace Microsoft.Azure.WebJobs.Script.WebHost.Controllers
{
///
- /// Controller responsible for handling all administrative requests, for
- /// example enqueueing function invocations, etc.
+ /// Controller responsible for handling all administrative requests for host operations
+ /// example host status, ping, log, etc
///
- public class AdminController : Controller
+ public class HostController : Controller
{
private readonly WebScriptHostManager _scriptHostManager;
private readonly WebHostSettings _webHostSettings;
private readonly ILogger _logger;
private readonly IAuthorizationService _authorizationService;
- public AdminController(WebScriptHostManager scriptHostManager, WebHostSettings webHostSettings, ILoggerFactory loggerFactory, IAuthorizationService authorizationService)
+ public HostController(WebScriptHostManager scriptHostManager, WebHostSettings webHostSettings, ILoggerFactory loggerFactory, IAuthorizationService authorizationService)
{
_scriptHostManager = scriptHostManager;
_webHostSettings = webHostSettings;
- _logger = loggerFactory.CreateLogger(ScriptConstants.LogCategoryAdminController);
+ _logger = loggerFactory.CreateLogger(ScriptConstants.LogCategoryHostController);
_authorizationService = authorizationService;
}
@@ -214,4 +218,4 @@ public async Task ExtensionWebHookHandler(string name, Cancellati
return NotFound();
}
}
-}
\ No newline at end of file
+}
diff --git a/src/WebJobs.Script.WebHost/Extensions/EntityTagHeaderValueExtensions.cs b/src/WebJobs.Script.WebHost/Extensions/EntityTagHeaderValueExtensions.cs
new file mode 100644
index 0000000000..7896d86823
--- /dev/null
+++ b/src/WebJobs.Script.WebHost/Extensions/EntityTagHeaderValueExtensions.cs
@@ -0,0 +1,20 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace Microsoft.Azure.WebJobs.Script.WebHost.Extensions
+{
+ public static class EntityTagHeaderValueExtensions
+ {
+ public static System.Net.Http.Headers.EntityTagHeaderValue ToSystemETag(this Microsoft.Net.Http.Headers.EntityTagHeaderValue value)
+ {
+ return value.Tag.Value.StartsWith("\"") && value.Tag.Value.EndsWith("\"")
+ ? new System.Net.Http.Headers.EntityTagHeaderValue(value.Tag.Value, value.IsWeak)
+ : new System.Net.Http.Headers.EntityTagHeaderValue($"\"{value.Tag}\"", value.IsWeak);
+ }
+ }
+}
diff --git a/src/WebJobs.Script.WebHost/Extensions/FunctionMetadataExtensions.cs b/src/WebJobs.Script.WebHost/Extensions/FunctionMetadataExtensions.cs
new file mode 100755
index 0000000000..0cd4c7e0da
--- /dev/null
+++ b/src/WebJobs.Script.WebHost/Extensions/FunctionMetadataExtensions.cs
@@ -0,0 +1,98 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+
+using System;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Azure.WebJobs.Script.Config;
+using Microsoft.Azure.WebJobs.Script.Description;
+using Microsoft.Azure.WebJobs.Script.Management.Models;
+using Microsoft.Azure.WebJobs.Script.WebHost.Helpers;
+using Microsoft.Azure.WebJobs.Script.WebHost.Management;
+using Newtonsoft.Json.Linq;
+
+namespace Microsoft.Azure.WebJobs.Script.WebHost.Extensions
+{
+ public static class FunctionMetadataExtensions
+ {
+ ///
+ /// Maps FunctionMetadata to FunctionMetadataResponse.
+ ///
+ /// FunctionMetadata to be mapped.
+ /// Current HttpRequest
+ /// ScriptHostConfig
+ /// Promise of a FunctionMetadataResponse
+ public static async Task ToFunctionMetadataResponse(this FunctionMetadata functionMetadata, HttpRequest request, ScriptHostConfiguration config)
+ {
+ var functionPath = Path.Combine(config.RootScriptPath, functionMetadata.Name);
+ var functionMetadataFilePath = Path.Combine(functionPath, ScriptConstants.FunctionMetadataFileName);
+ var baseUrl = request != null
+ ? $"{request.Scheme}://{request.Host}"
+ : "https://localhost/";
+
+ var response = new FunctionMetadataResponse
+ {
+ Name = functionMetadata.Name,
+
+ // Q: can functionMetadata.ScriptFile be null or empty?
+ ScriptHref = VirtualFileSystem.FilePathToVfsUri(Path.Combine(config.RootScriptPath, functionMetadata.ScriptFile), baseUrl, config),
+ ConfigHref = VirtualFileSystem.FilePathToVfsUri(functionMetadataFilePath, baseUrl, config),
+ ScriptRootPathHref = VirtualFileSystem.FilePathToVfsUri(functionPath, baseUrl, config, isDirectory: true),
+ TestDataHref = VirtualFileSystem.FilePathToVfsUri(functionMetadata.GetTestDataFilePath(config), baseUrl, config),
+ Href = GetFunctionHref(functionMetadata.Name, baseUrl),
+ TestData = await GetTestData(functionMetadata.GetTestDataFilePath(config), config),
+ Config = await GetFunctionConfig(functionMetadataFilePath, config),
+
+ // Properties below this comment are not present in the kudu version.
+ IsDirect = functionMetadata.IsDirect,
+ IsDisabled = functionMetadata.IsDisabled,
+ IsProxy = functionMetadata.IsProxy
+ };
+ return response;
+ }
+
+ public static string GetTestDataFilePath(this FunctionMetadata functionMetadata, ScriptHostConfiguration config) =>
+ GetTestDataFilePath(functionMetadata.Name, config);
+
+ public static string GetTestDataFilePath(string functionName, ScriptHostConfiguration config) =>
+ Path.Combine(config.TestDataPath, $"{functionName}.dat");
+
+ private static async Task GetFunctionConfig(string path, ScriptHostConfiguration config)
+ {
+ try
+ {
+ if (FileUtility.FileExists(path))
+ {
+ using (var reader = File.OpenText(path))
+ {
+ return JObject.Parse(await reader.ReadToEndAsync());
+ }
+ }
+ }
+ catch
+ {
+ // no-op
+ }
+
+ // If there are any errors parsing function.json return an empty object.
+ // This is current kudu behavior.
+ // TODO: add an error field that can be used to communicate the JSON parse error.
+ return new JObject();
+ }
+
+ private static async Task GetTestData(string testDataPath, ScriptHostConfiguration config)
+ {
+ if (!File.Exists(testDataPath))
+ {
+ await FileUtility.WriteAsync(testDataPath, string.Empty);
+ }
+
+ return await FileUtility.ReadAsync(testDataPath);
+ }
+
+ private static Uri GetFunctionHref(string functionName, string baseUrl) =>
+ new Uri($"{baseUrl}/admin/functions/{functionName}");
+ }
+}
\ No newline at end of file
diff --git a/src/WebJobs.Script.WebHost/Extensions/FunctionMetadataResponseExtensions.cs b/src/WebJobs.Script.WebHost/Extensions/FunctionMetadataResponseExtensions.cs
new file mode 100755
index 0000000000..a16a6ee042
--- /dev/null
+++ b/src/WebJobs.Script.WebHost/Extensions/FunctionMetadataResponseExtensions.cs
@@ -0,0 +1,21 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Microsoft.Azure.WebJobs.Script.Management.Models;
+using Microsoft.Azure.WebJobs.Script.WebHost.Management;
+
+namespace Microsoft.Azure.WebJobs.Script.WebHost.Extensions
+{
+ public static class FunctionMetadataResponseExtensions
+ {
+ public static string GetFunctionPath(this FunctionMetadataResponse function, ScriptHostConfiguration config)
+ => VirtualFileSystem.VfsUriToFilePath(function.ScriptRootPathHref, config);
+
+ public static string GetFunctionTestDataFilePath(this FunctionMetadataResponse function, ScriptHostConfiguration config)
+ => VirtualFileSystem.VfsUriToFilePath(function.TestDataHref, config);
+ }
+}
\ No newline at end of file
diff --git a/src/WebJobs.Script.WebHost/Extensions/HttpHeadersExtensions.cs b/src/WebJobs.Script.WebHost/Extensions/HttpHeadersExtensions.cs
new file mode 100755
index 0000000000..e826ce62cb
--- /dev/null
+++ b/src/WebJobs.Script.WebHost/Extensions/HttpHeadersExtensions.cs
@@ -0,0 +1,21 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net.Http.Headers;
+using Microsoft.Extensions.Primitives;
+
+namespace Microsoft.Azure.WebJobs.Script.WebHost.Extensions
+{
+ public static class HttpHeadersExtensions
+ {
+ public static Dictionary ToCoreHeaders(this HttpHeaders headers, params string[] exclude)
+ {
+ return headers
+ .Where(h => !exclude.Any(e => e.Equals(h.Key, StringComparison.OrdinalIgnoreCase)))
+ .ToDictionary(k => k.Key, v => new StringValues(v.Value.ToArray()));
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/WebJobs.Script.WebHost/Extensions/HttpResponseMessageExtensions.cs b/src/WebJobs.Script.WebHost/Extensions/HttpResponseMessageExtensions.cs
new file mode 100644
index 0000000000..96ce9b3f0f
--- /dev/null
+++ b/src/WebJobs.Script.WebHost/Extensions/HttpResponseMessageExtensions.cs
@@ -0,0 +1,33 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+
+using System;
+using System.Net.Http;
+using System.Net.Http.Headers;
+
+namespace Microsoft.Azure.WebJobs.Script.WebHost.Extensions
+{
+ public static class HttpResponseMessageExtensions
+ {
+ public static void SetEntityTagHeader(this HttpResponseMessage httpResponseMessage, EntityTagHeaderValue etag, DateTime lastModified)
+ {
+ if (httpResponseMessage.Content == null)
+ {
+ httpResponseMessage.Content = new NullContent();
+ }
+
+ httpResponseMessage.Headers.ETag = etag;
+ httpResponseMessage.Content.Headers.LastModified = lastModified;
+ }
+
+ private class NullContent : StringContent
+ {
+ public NullContent()
+ : base(string.Empty)
+ {
+ Headers.ContentType = null;
+ Headers.ContentLength = null;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/WebJobs.Script.WebHost/Extensions/IEnumerableTasksExtensions.cs b/src/WebJobs.Script.WebHost/Extensions/IEnumerableTasksExtensions.cs
new file mode 100644
index 0000000000..fe822aa240
--- /dev/null
+++ b/src/WebJobs.Script.WebHost/Extensions/IEnumerableTasksExtensions.cs
@@ -0,0 +1,31 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Microsoft.Azure.WebJobs.Script.WebHost.Extensions
+{
+ public static class IEnumerableTasksExtensions
+ {
+ ///
+ /// Returns a Task that completes when all the tasks in have completed.
+ ///
+ /// tasks to be reduced.
+ /// Task that completes when all tasks are done.
+ public static Task WhenAll(this IEnumerable collection)
+ {
+ return Task.WhenAll(collection);
+ }
+
+ ///
+ /// Returns a Task that completes when all the tasks in have completed.
+ ///
+ /// tasks to be reduced.
+ /// Task that completes when all tasks are done.
+ public static Task WhenAll(this IEnumerable> collection)
+ {
+ return Task.WhenAll(collection);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/WebJobs.Script.WebHost/Extensions/VfsStringExtensions.cs b/src/WebJobs.Script.WebHost/Extensions/VfsStringExtensions.cs
new file mode 100644
index 0000000000..cf2b4ef3a1
--- /dev/null
+++ b/src/WebJobs.Script.WebHost/Extensions/VfsStringExtensions.cs
@@ -0,0 +1,15 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+
+using System;
+
+namespace Microsoft.Azure.WebJobs.Script.WebHost.Extensions
+{
+ public static class VfsStringExtensions
+ {
+ public static string EscapeHashCharacter(this string str)
+ {
+ return str.Replace("#", Uri.EscapeDataString("#"));
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/WebJobs.Script.WebHost/Extensions/WebHostSettingsExtensions.cs b/src/WebJobs.Script.WebHost/Extensions/WebHostSettingsExtensions.cs
new file mode 100644
index 0000000000..ad7d825f18
--- /dev/null
+++ b/src/WebJobs.Script.WebHost/Extensions/WebHostSettingsExtensions.cs
@@ -0,0 +1,11 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+
+namespace Microsoft.Azure.WebJobs.Script.WebHost.Extensions
+{
+ public static class WebHostSettingsExtensions
+ {
+ public static ScriptHostConfiguration ToScriptHostConfiguration(this WebHostSettings webHostSettings) =>
+ WebHostResolver.CreateScriptHostConfiguration(webHostSettings);
+ }
+}
\ No newline at end of file
diff --git a/src/WebJobs.Script.WebHost/Helpers/MediaTypeMap.cs b/src/WebJobs.Script.WebHost/Helpers/MediaTypeMap.cs
new file mode 100644
index 0000000000..4d821342ca
--- /dev/null
+++ b/src/WebJobs.Script.WebHost/Helpers/MediaTypeMap.cs
@@ -0,0 +1,63 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Concurrent;
+using System.Net.Http.Headers;
+using Microsoft.AspNetCore.StaticFiles;
+
+namespace Microsoft.Azure.WebJobs.Script.WebHost.Helpers
+{
+ public class MediaTypeMap
+ {
+ private static readonly MediaTypeMap _defaultInstance = new MediaTypeMap();
+ private static readonly FileExtensionContentTypeProvider _mimeMapping = new FileExtensionContentTypeProvider();
+ private readonly ConcurrentDictionary _mediatypeMap = CreateMediaTypeMap();
+ private readonly MediaTypeHeaderValue _defaultMediaType = MediaTypeHeaderValue.Parse("application/octet-stream");
+
+ public static MediaTypeMap Default
+ {
+ get { return _defaultInstance; }
+ }
+
+ public MediaTypeHeaderValue GetMediaType(string fileExtension)
+ {
+ if (fileExtension == null)
+ {
+ throw new ArgumentNullException(nameof(fileExtension));
+ }
+
+ return _mediatypeMap.GetOrAdd(fileExtension,
+ (extension) =>
+ {
+ try
+ {
+ if (_mimeMapping.TryGetContentType(fileExtension, out string mediaTypeValue))
+ {
+ if (MediaTypeHeaderValue.TryParse(mediaTypeValue, out MediaTypeHeaderValue mediaType))
+ {
+ return mediaType;
+ }
+ }
+ return _defaultMediaType;
+ }
+ catch
+ {
+ return _defaultMediaType;
+ }
+ });
+ }
+
+ private static ConcurrentDictionary CreateMediaTypeMap()
+ {
+ var dictionary = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase);
+ dictionary.TryAdd(".js", MediaTypeHeaderValue.Parse("application/javascript"));
+ dictionary.TryAdd(".json", MediaTypeHeaderValue.Parse("application/json"));
+
+ // Add media type for markdown
+ dictionary.TryAdd(".md", MediaTypeHeaderValue.Parse("text/plain"));
+
+ return dictionary;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/WebJobs.Script.WebHost/Helpers/VfsSpecialFolders.cs b/src/WebJobs.Script.WebHost/Helpers/VfsSpecialFolders.cs
new file mode 100755
index 0000000000..c48475d1aa
--- /dev/null
+++ b/src/WebJobs.Script.WebHost/Helpers/VfsSpecialFolders.cs
@@ -0,0 +1,158 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Net;
+using System.Net.Http;
+using System.Runtime.InteropServices;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Azure.WebJobs.Script.Extensions;
+using Microsoft.Azure.WebJobs.Script.WebHost.Extensions;
+using Microsoft.Azure.WebJobs.Script.WebHost.Management;
+using Microsoft.Azure.WebJobs.Script.WebHost.Models;
+
+namespace Microsoft.Azure.WebJobs.Script.WebHost.Helpers
+{
+ public static class VfsSpecialFolders
+ {
+ private const string SystemDriveFolder = "SystemDrive";
+ private const string LocalSiteRootFolder = "LocalSiteRoot";
+
+ private static string _systemDrivePath;
+ private static string _localSiteRootPath;
+
+ public static string SystemDrivePath
+ {
+ get
+ {
+ // only return a system drive for Windows. Unix always assums / as fs root.
+ if (_systemDrivePath == null && RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ _systemDrivePath = Environment.GetEnvironmentVariable(SystemDriveFolder) ?? string.Empty;
+ }
+
+ return _systemDrivePath;
+ }
+ }
+
+ public static string LocalSiteRootPath
+ {
+ get
+ {
+ if (_localSiteRootPath == null && RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ // only light up in Azure env
+ string tmpPath = Environment.GetEnvironmentVariable("TMP");
+ if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WEBSITE_SITE_NAME")) &&
+ !string.IsNullOrEmpty(tmpPath))
+ {
+ _localSiteRootPath = Path.GetDirectoryName(tmpPath);
+ }
+ }
+
+ return _localSiteRootPath;
+ }
+ // internal for testing purpose
+ internal set
+ {
+ _localSiteRootPath = value;
+ }
+ }
+
+ public static IEnumerable GetEntries(string baseAddress, string query)
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ if (!string.IsNullOrEmpty(SystemDrivePath))
+ {
+ var dir = FileUtility.DirectoryInfoFromDirectoryName(SystemDrivePath + Path.DirectorySeparatorChar);
+ yield return new VfsStatEntry
+ {
+ Name = SystemDriveFolder,
+ MTime = dir.LastWriteTimeUtc,
+ CRTime = dir.CreationTimeUtc,
+ Mime = "inode/shortcut",
+ Href = baseAddress + Uri.EscapeUriString(SystemDriveFolder + VirtualFileSystem.UriSegmentSeparator) + query,
+ Path = dir.FullName
+ };
+ }
+
+ if (!string.IsNullOrEmpty(LocalSiteRootPath))
+ {
+ var dir = FileUtility.DirectoryInfoFromDirectoryName(LocalSiteRootPath);
+ yield return new VfsStatEntry
+ {
+ Name = LocalSiteRootFolder,
+ MTime = dir.LastWriteTimeUtc,
+ CRTime = dir.CreationTimeUtc,
+ Mime = "inode/shortcut",
+ Href = baseAddress + Uri.EscapeUriString(LocalSiteRootFolder + VirtualFileSystem.UriSegmentSeparator) + query,
+ Path = dir.FullName
+ };
+ }
+ }
+ }
+
+ public static bool TryHandleRequest(HttpRequest request, string path, out HttpResponseMessage response)
+ {
+ response = null;
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) &&
+ string.Equals(path, SystemDrivePath, StringComparison.OrdinalIgnoreCase))
+ {
+ response = new HttpResponseMessage(HttpStatusCode.TemporaryRedirect);
+ UriBuilder location = new UriBuilder(request.GetRequestUri());
+ location.Path += "/";
+ response.Headers.Location = location.Uri;
+ }
+
+ return response != null;
+ }
+
+ // this resolves the special folders such as SystemDrive or LocalSiteRoot
+ public static bool TryParse(string path, out string result)
+ {
+ result = null;
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) &&
+ !string.IsNullOrEmpty(path))
+ {
+ if (string.Equals(path, SystemDriveFolder, StringComparison.OrdinalIgnoreCase) ||
+ path.IndexOf(SystemDriveFolder + VirtualFileSystem.UriSegmentSeparator, StringComparison.OrdinalIgnoreCase) == 0)
+ {
+ if (!string.IsNullOrEmpty(SystemDrivePath))
+ {
+ string relativePath = path.Substring(SystemDriveFolder.Length);
+ if (string.IsNullOrEmpty(relativePath))
+ {
+ result = SystemDrivePath;
+ }
+ else
+ {
+ result = Path.GetFullPath(SystemDrivePath + relativePath);
+ }
+ }
+ }
+ else if (string.Equals(path, LocalSiteRootFolder, StringComparison.OrdinalIgnoreCase) ||
+ path.IndexOf(LocalSiteRootFolder + VirtualFileSystem.UriSegmentSeparator, StringComparison.OrdinalIgnoreCase) == 0)
+ {
+ if (!string.IsNullOrEmpty(LocalSiteRootPath))
+ {
+ string relativePath = path.Substring(LocalSiteRootFolder.Length);
+ if (string.IsNullOrEmpty(relativePath))
+ {
+ result = LocalSiteRootPath;
+ }
+ else
+ {
+ result = Path.GetFullPath(LocalSiteRootPath + relativePath);
+ }
+ }
+ }
+ }
+
+ return result != null;
+ }
+ }
+}
diff --git a/src/WebJobs.Script.WebHost/Management/IWebFunctionsManager.cs b/src/WebJobs.Script.WebHost/Management/IWebFunctionsManager.cs
new file mode 100755
index 0000000000..22435bc67c
--- /dev/null
+++ b/src/WebJobs.Script.WebHost/Management/IWebFunctionsManager.cs
@@ -0,0 +1,21 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Azure.WebJobs.Script.Management.Models;
+
+namespace Microsoft.Azure.WebJobs.Script.WebHost.Management
+{
+ public interface IWebFunctionsManager
+ {
+ Task> GetFunctionsMetadata(HttpRequest request);
+
+ Task<(bool, FunctionMetadataResponse)> TryGetFunction(string name, HttpRequest request);
+
+ Task<(bool, bool, FunctionMetadataResponse)> CreateOrUpdate(string name, FunctionMetadataResponse functionMetadata, HttpRequest request);
+
+ (bool, string) TryDeleteFunction(FunctionMetadataResponse function);
+ }
+}
diff --git a/src/WebJobs.Script.WebHost/Management/VirtualFileSystem.cs b/src/WebJobs.Script.WebHost/Management/VirtualFileSystem.cs
new file mode 100644
index 0000000000..6d9cd99892
--- /dev/null
+++ b/src/WebJobs.Script.WebHost/Management/VirtualFileSystem.cs
@@ -0,0 +1,630 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Abstractions;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Runtime.InteropServices;
+using System.Text;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Azure.WebJobs.Script.Config;
+using Microsoft.Azure.WebJobs.Script.Extensions;
+using Microsoft.Azure.WebJobs.Script.WebHost.Extensions;
+using Microsoft.Azure.WebJobs.Script.WebHost.Helpers;
+using Microsoft.Azure.WebJobs.Script.WebHost.Models;
+using Newtonsoft.Json;
+
+namespace Microsoft.Azure.WebJobs.Script.WebHost.Management
+{
+ public class VirtualFileSystem
+ {
+ public const char UriSegmentSeparator = '/';
+ private const string DirectoryEnumerationSearchPattern = "*";
+ private const string DummyRazorExtension = ".func777";
+
+ private static readonly char[] _uriSegmentSeparator = new char[] { UriSegmentSeparator };
+ private static readonly MediaTypeHeaderValue _directoryMediaType = MediaTypeHeaderValue.Parse("inode/directory");
+
+ protected const int BufferSize = 32 * 1024;
+ private readonly ScriptHostConfiguration _config;
+
+ public VirtualFileSystem(WebHostSettings settings)
+ {
+ _config = settings.ToScriptHostConfiguration();
+ MediaTypeMap = MediaTypeMap.Default;
+ }
+
+ protected string RootPath
+ {
+ get
+ {
+ return RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
+ ? ScriptSettingsManager.Instance.GetSetting(EnvironmentSettingNames.AzureWebsiteHomePath) ?? Path.GetFullPath(_config.RootScriptPath)
+ : Path.DirectorySeparatorChar.ToString();
+ }
+ }
+
+ protected MediaTypeMap MediaTypeMap { get; private set; }
+
+ public virtual Task GetItem(HttpRequest request)
+ {
+ string localFilePath = GetLocalFilePath(request);
+
+ if (VfsSpecialFolders.TryHandleRequest(request, localFilePath, out HttpResponseMessage response))
+ {
+ return Task.FromResult(response);
+ }
+
+ var info = FileUtility.DirectoryInfoFromDirectoryName(localFilePath);
+
+ if (info.Attributes < 0)
+ {
+ var notFoundResponse = CreateResponse(HttpStatusCode.NotFound, string.Format("'{0}' not found.", info.FullName));
+ return Task.FromResult(notFoundResponse);
+ }
+ else if ((info.Attributes & FileAttributes.Directory) != 0)
+ {
+ // If request URI does NOT end in a "/" then redirect to one that does
+ var uri = request.GetRequestUri();
+ if (!uri.AbsolutePath.EndsWith("/"))
+ {
+ var redirectResponse = CreateResponse(HttpStatusCode.TemporaryRedirect);
+ var location = new UriBuilder(uri);
+ location.Path += "/";
+ redirectResponse.Headers.Location = location.Uri;
+ return Task.FromResult(redirectResponse);
+ }
+ else
+ {
+ return CreateDirectoryGetResponse(request, info, localFilePath);
+ }
+ }
+ else
+ {
+ // If request URI ends in a "/" then redirect to one that does not
+ if (localFilePath[localFilePath.Length - 1] == Path.DirectorySeparatorChar)
+ {
+ HttpResponseMessage redirectResponse = CreateResponse(HttpStatusCode.TemporaryRedirect);
+ UriBuilder location = new UriBuilder(request.GetRequestUri());
+ location.Path = location.Path.TrimEnd(_uriSegmentSeparator);
+ redirectResponse.Headers.Location = location.Uri;
+ return Task.FromResult(redirectResponse);
+ }
+
+ // We are ready to get the file
+ return CreateItemGetResponse(request, info, localFilePath);
+ }
+ }
+
+ public virtual Task PutItem(HttpRequest request)
+ {
+ var localFilePath = GetLocalFilePath(request);
+
+ if (VfsSpecialFolders.TryHandleRequest(request, localFilePath, out HttpResponseMessage response))
+ {
+ return Task.FromResult(response);
+ }
+
+ var info = FileUtility.DirectoryInfoFromDirectoryName(localFilePath);
+ var itemExists = info.Attributes >= 0;
+
+ if (itemExists && (info.Attributes & FileAttributes.Directory) != 0)
+ {
+ return CreateDirectoryPutResponse(request, info, localFilePath);
+ }
+ else
+ {
+ // If request URI ends in a "/" then attempt to create the directory.
+ if (localFilePath[localFilePath.Length - 1] == Path.DirectorySeparatorChar)
+ {
+ return CreateDirectoryPutResponse(request, info, localFilePath);
+ }
+
+ // We are ready to update the file
+ return CreateItemPutResponse(request, info, localFilePath, itemExists);
+ }
+ }
+
+ public virtual Task DeleteItem(HttpRequest request, bool recursive = false)
+ {
+ string localFilePath = GetLocalFilePath(request);
+
+ if (VfsSpecialFolders.TryHandleRequest(request, localFilePath, out HttpResponseMessage response))
+ {
+ return Task.FromResult(response);
+ }
+
+ var dirInfo = FileUtility.DirectoryInfoFromDirectoryName(localFilePath);
+
+ if (dirInfo.Attributes < 0)
+ {
+ var notFoundResponse = CreateResponse(HttpStatusCode.NotFound, string.Format("'{0}' not found.", dirInfo.FullName));
+ return Task.FromResult(notFoundResponse);
+ }
+ else if ((dirInfo.Attributes & FileAttributes.Directory) != 0)
+ {
+ try
+ {
+ dirInfo.Delete(recursive);
+ }
+ catch (Exception ex)
+ {
+ // TODO: log ex
+ var conflictDirectoryResponse = CreateResponse(HttpStatusCode.Conflict, ex);
+ return Task.FromResult(conflictDirectoryResponse);
+ }
+
+ // Delete directory succeeded.
+ var successResponse = CreateResponse(HttpStatusCode.OK);
+ return Task.FromResult(successResponse);
+ }
+ else
+ {
+ // If request URI ends in a "/" then redirect to one that does not
+ if (localFilePath[localFilePath.Length - 1] == Path.DirectorySeparatorChar)
+ {
+ var redirectResponse = CreateResponse(HttpStatusCode.TemporaryRedirect);
+ var location = new UriBuilder(request.GetRequestUri());
+ location.Path = location.Path.TrimEnd(_uriSegmentSeparator);
+ redirectResponse.Headers.Location = location.Uri;
+ return Task.FromResult(redirectResponse);
+ }
+
+ // We are ready to delete the file
+ var fileInfo = FileUtility.FileInfoFromFileName(localFilePath);
+ return CreateFileDeleteResponse(request, fileInfo);
+ }
+ }
+
+ public static Uri FilePathToVfsUri(string filePath, string baseUrl, ScriptHostConfiguration config, bool isDirectory = false)
+ {
+ var home = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
+ ? ScriptSettingsManager.Instance.GetSetting(EnvironmentSettingNames.AzureWebsiteHomePath) ?? config.RootScriptPath
+ : Path.DirectorySeparatorChar.ToString();
+
+ filePath = filePath
+ .Substring(home.Length)
+ .Trim('\\', '/')
+ .Replace("\\", "/");
+
+ return new Uri($"{baseUrl}/admin/vfs/{filePath}{(isDirectory ? "/" : string.Empty)}");
+ }
+
+ public static string VfsUriToFilePath(Uri uri, ScriptHostConfiguration config, bool isDirectory = false)
+ {
+ var home = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
+ ? ScriptSettingsManager.Instance.GetSetting(EnvironmentSettingNames.AzureWebsiteHomePath) ?? config.RootScriptPath
+ : Path.DirectorySeparatorChar.ToString();
+
+ var filePath = uri.AbsolutePath.Split("/admin/vfs").LastOrDefault();
+ filePath = string.IsNullOrEmpty(filePath)
+ ? home
+ : Path.Combine(home, filePath.TrimStart('/'));
+
+ return filePath.Replace('/', Path.DirectorySeparatorChar);
+ }
+
+ protected virtual Task CreateDirectoryGetResponse(HttpRequest request, DirectoryInfoBase info, string localFilePath)
+ {
+ if (info == null)
+ {
+ throw new ArgumentNullException(nameof(info));
+ }
+
+ try
+ {
+ // Enumerate directory
+ var directory = GetDirectoryResponse(request, info.GetFileSystemInfos());
+ var successDirectoryResponse = CreateResponse(HttpStatusCode.OK, directory);
+ return Task.FromResult(successDirectoryResponse);
+ }
+ catch (Exception e)
+ {
+ // TODO: log
+ HttpResponseMessage errorResponse = CreateResponse(HttpStatusCode.InternalServerError, e.Message);
+ return Task.FromResult(errorResponse);
+ }
+ }
+
+ protected Task CreateItemGetResponse(HttpRequest request, FileSystemInfoBase info, string localFilePath)
+ {
+ // Get current etag
+ var currentEtag = CreateEntityTag(info);
+ var lastModified = info.LastWriteTimeUtc;
+
+ // Check whether we have a range request (taking If-Range condition into account)
+ bool isRangeRequest = IsRangeRequest(request, currentEtag);
+
+ // Check whether we have a conditional If-None-Match request
+ // Unless it is a range request (see RFC2616 sec 14.35.2 Range Retrieval Requests)
+ if (!isRangeRequest && IsIfNoneMatchRequest(request, currentEtag.ToSystemETag()))
+ {
+ var notModifiedResponse = CreateResponse(HttpStatusCode.NotModified);
+ notModifiedResponse.SetEntityTagHeader(currentEtag.ToSystemETag(), lastModified);
+ return Task.FromResult(notModifiedResponse);
+ }
+
+ // Generate file response
+ Stream fileStream = null;
+ try
+ {
+ fileStream = GetFileReadStream(localFilePath);
+ var mediaType = MediaTypeMap.GetMediaType(info.Extension);
+ var successFileResponse = CreateResponse(isRangeRequest ? HttpStatusCode.PartialContent : HttpStatusCode.OK);
+
+ if (isRangeRequest)
+ {
+ var typedHeaders = request.GetTypedHeaders();
+ var rangeHeader = new RangeHeaderValue
+ {
+ Unit = typedHeaders.Range.Unit.Value
+ };
+
+ foreach (var range in typedHeaders.Range.Ranges)
+ {
+ rangeHeader.Ranges.Add(new RangeItemHeaderValue(range.From, range.To));
+ }
+
+ successFileResponse.Content = new ByteRangeStreamContent(fileStream, rangeHeader, mediaType, BufferSize);
+ }
+ else
+ {
+ successFileResponse.Content = new StreamContent(fileStream, BufferSize);
+ successFileResponse.Content.Headers.ContentType = mediaType;
+ }
+
+ // Set etag for the file
+ successFileResponse.SetEntityTagHeader(currentEtag.ToSystemETag(), lastModified);
+ return Task.FromResult(successFileResponse);
+ }
+ catch (InvalidByteRangeException invalidByteRangeException)
+ {
+ // The range request had no overlap with the current extend of the resource so generate a 416 (Requested Range Not Satisfiable)
+ // including a Content-Range header with the current size.
+ // TODO: log?
+ var invalidByteRangeResponse = CreateResponse(HttpStatusCode.RequestedRangeNotSatisfiable, invalidByteRangeException);
+ if (fileStream != null)
+ {
+ fileStream.Close();
+ }
+ return Task.FromResult(invalidByteRangeResponse);
+ }
+ catch (Exception ex)
+ {
+ // Could not read the file
+ // TODO: log?
+ var errorResponse = CreateResponse(HttpStatusCode.NotFound, ex);
+ if (fileStream != null)
+ {
+ fileStream.Close();
+ }
+ return Task.FromResult(errorResponse);
+ }
+ }
+
+ protected Task CreateDirectoryPutResponse(HttpRequest request, DirectoryInfoBase info, string localFilePath)
+ {
+ if (info != null && info.Exists)
+ {
+ // Return a conflict result
+ var conflictDirectoryResponse = CreateResponse(HttpStatusCode.Conflict);
+ return Task.FromResult(conflictDirectoryResponse);
+ }
+
+ try
+ {
+ info.Create();
+ }
+ catch (IOException ex)
+ {
+ // TODO: log ex
+ HttpResponseMessage conflictDirectoryResponse = CreateResponse(HttpStatusCode.Conflict);
+ return Task.FromResult(conflictDirectoryResponse);
+ }
+
+ // Return 201 Created response
+ var successFileResponse = CreateResponse(HttpStatusCode.Created);
+ return Task.FromResult(successFileResponse);
+ }
+
+ protected async Task CreateItemPutResponse(HttpRequest request, FileSystemInfoBase info, string localFilePath, bool itemExists)
+ {
+ // Check that we have a matching conditional If-Match request for existing resources
+ if (itemExists)
+ {
+ // Get current etag
+ var currentEtag = CreateEntityTag(info);
+ var typedHeaders = request.GetTypedHeaders();
+ // Existing resources require an etag to be updated.
+ if (typedHeaders.IfMatch == null)
+ {
+ var missingIfMatchResponse = CreateResponse(HttpStatusCode.PreconditionFailed, "missing If-Match");
+ return missingIfMatchResponse;
+ }
+
+ bool isMatch = false;
+ foreach (var etag in typedHeaders.IfMatch)
+ {
+ if (currentEtag.Equals(etag) || etag == Microsoft.Net.Http.Headers.EntityTagHeaderValue.Any)
+ {
+ isMatch = true;
+ break;
+ }
+ }
+
+ if (!isMatch)
+ {
+ var conflictFileResponse = CreateResponse(HttpStatusCode.PreconditionFailed, "Etag mismatch");
+ conflictFileResponse.Headers.ETag = currentEtag.ToSystemETag();
+ return conflictFileResponse;
+ }
+ }
+
+ // Save file
+ try
+ {
+ using (Stream fileStream = GetFileWriteStream(localFilePath, fileExists: itemExists))
+ {
+ try
+ {
+ await request.Body.CopyToAsync(fileStream);
+ }
+ catch (Exception ex)
+ {
+ // TODO: log ex
+ var conflictResponse = CreateResponse(HttpStatusCode.Conflict, ex);
+ return conflictResponse;
+ }
+ }
+
+ // Return either 204 No Content or 201 Created response
+ var successFileResponse = CreateResponse(itemExists ? HttpStatusCode.NoContent : HttpStatusCode.Created);
+
+ // Set updated etag for the file
+ info.Refresh();
+ successFileResponse.SetEntityTagHeader(CreateEntityTag(info).ToSystemETag(), info.LastWriteTimeUtc);
+ return successFileResponse;
+ }
+ catch (Exception ex)
+ {
+ // TODO: log ex
+ var errorResponse = CreateResponse(HttpStatusCode.Conflict, ex);
+ return errorResponse;
+ }
+ }
+
+ protected Task CreateFileDeleteResponse(HttpRequest request, FileInfoBase info)
+ {
+ // Existing resources require an etag to be updated.
+ var typedHeaders = request.GetTypedHeaders();
+ if (typedHeaders.IfMatch == null)
+ {
+ var conflictDirectoryResponse = CreateResponse(HttpStatusCode.PreconditionFailed, "Missing If-Match");
+ return Task.FromResult(conflictDirectoryResponse);
+ }
+
+ // Get current etag
+ var currentEtag = CreateEntityTag(info);
+ var isMatch = typedHeaders.IfMatch.Any(etag => etag == Microsoft.Net.Http.Headers.EntityTagHeaderValue.Any || currentEtag.Equals(etag));
+
+ if (!isMatch)
+ {
+ var conflictFileResponse = CreateResponse(HttpStatusCode.PreconditionFailed, "Etag mismatch");
+ conflictFileResponse.Headers.ETag = currentEtag.ToSystemETag();
+ return Task.FromResult(conflictFileResponse);
+ }
+
+ try
+ {
+ using (Stream fileStream = GetFileDeleteStream(info))
+ {
+ info.Delete();
+ }
+ var successResponse = CreateResponse(HttpStatusCode.OK);
+ return Task.FromResult(successResponse);
+ }
+ catch (Exception e)
+ {
+ // Could not delete the file
+ // TODO: log ex
+ var notFoundResponse = CreateResponse(HttpStatusCode.NotFound, e);
+ return Task.FromResult(notFoundResponse);
+ }
+ }
+
+ ///
+ /// Indicates whether this is a conditional range request containing an
+ /// If-Range header with a matching etag and a Range header indicating the
+ /// desired ranges
+ ///
+ protected bool IsRangeRequest(HttpRequest request, Net.Http.Headers.EntityTagHeaderValue currentEtag)
+ {
+ var typedHeaders = request.GetTypedHeaders();
+ if (typedHeaders.Range == null)
+ {
+ return false;
+ }
+ if (typedHeaders.IfRange != null)
+ {
+ return typedHeaders.IfRange.EntityTag.Equals(currentEtag);
+ }
+ return true;
+ }
+
+ ///
+ /// Indicates whether this is a If-None-Match request with a matching etag.
+ ///
+ protected bool IsIfNoneMatchRequest(HttpRequest request, EntityTagHeaderValue currentEtag)
+ {
+ var typedHeaders = request.GetTypedHeaders();
+ return currentEtag != null && typedHeaders.IfNoneMatch != null &&
+ typedHeaders.IfNoneMatch.Any(entityTag => currentEtag.Equals(entityTag));
+ }
+
+ ///
+ /// Provides a common way for opening a file stream for shared reading from a file.
+ ///
+ protected static Stream GetFileReadStream(string localFilePath)
+ {
+ if (localFilePath == null)
+ {
+ throw new ArgumentNullException(nameof(localFilePath));
+ }
+
+ // Open file exclusively for read-sharing
+ return new FileStream(localFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, BufferSize, useAsync: true);
+ }
+
+ ///
+ /// Provides a common way for opening a file stream for writing exclusively to a file.
+ ///
+ protected static Stream GetFileWriteStream(string localFilePath, bool fileExists)
+ {
+ if (localFilePath == null)
+ {
+ throw new ArgumentNullException(nameof(localFilePath));
+ }
+
+ // Create path if item doesn't already exist
+ if (!fileExists)
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(localFilePath));
+ }
+
+ // Open file exclusively for write without any sharing
+ return new FileStream(localFilePath, FileMode.Create, FileAccess.Write, FileShare.None, BufferSize, useAsync: true);
+ }
+
+ ///
+ /// Provides a common way for opening a file stream for exclusively deleting the file.
+ ///
+ private static Stream GetFileDeleteStream(FileInfoBase file)
+ {
+ if (file == null)
+ {
+ throw new ArgumentNullException(nameof(file));
+ }
+
+ // Open file exclusively for delete sharing only
+ return file.Open(FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
+ }
+
+ ///
+ /// Create unique etag based on the last modified UTC time
+ ///
+ private static Microsoft.Net.Http.Headers.EntityTagHeaderValue CreateEntityTag(FileSystemInfoBase sysInfo)
+ {
+ if (sysInfo == null)
+ {
+ throw new ArgumentNullException(nameof(sysInfo));
+ }
+
+ var etag = BitConverter.GetBytes(sysInfo.LastWriteTimeUtc.Ticks);
+
+ var result = new StringBuilder(2 + (etag.Length * 2));
+ result.Append("\"");
+ foreach (byte b in etag)
+ {
+ result.AppendFormat("{0:x2}", b);
+ }
+ result.Append("\"");
+ return new Microsoft.Net.Http.Headers.EntityTagHeaderValue(result.ToString());
+ }
+
+ // internal for testing purpose
+ internal string GetLocalFilePath(HttpRequest request)
+ {
+ // Restore the original extension if we had added a dummy
+ // See comment in TraceModule.OnBeginRequest
+ string result = GetOriginalLocalFilePath(request);
+ if (result.EndsWith(DummyRazorExtension, StringComparison.Ordinal))
+ {
+ result = result.Substring(0, result.Length - DummyRazorExtension.Length);
+ }
+
+ return result;
+ }
+
+ private string GetOriginalLocalFilePath(HttpRequest request)
+ {
+ string result = null;
+ PathString path = null;
+ if (request.Path.StartsWithSegments("/admin/vfs", out path) ||
+ request.Path.StartsWithSegments("/admin/zip", out path))
+ {
+ if (VfsSpecialFolders.TryParse(path, out result))
+ {
+ return result;
+ }
+ }
+
+ result = RootPath;
+ if (path != null && path.HasValue)
+ {
+ result = Path.GetFullPath(Path.Combine(result, path.Value.TrimStart('/')));
+ }
+ else
+ {
+ string reqUri = request.GetRequestUri().AbsoluteUri.Split('?').First();
+ if (reqUri[reqUri.Length - 1] == UriSegmentSeparator)
+ {
+ result = Path.GetFullPath(result + Path.DirectorySeparatorChar);
+ }
+ }
+ return result;
+ }
+
+ private IEnumerable GetDirectoryResponse(HttpRequest request, FileSystemInfoBase[] infos)
+ {
+ var uri = request.GetRequestUri();
+ string baseAddress = uri.AbsoluteUri.Split('?').First();
+ string query = uri.Query;
+ foreach (FileSystemInfoBase fileSysInfo in infos)
+ {
+ bool isDirectory = (fileSysInfo.Attributes & FileAttributes.Directory) != 0;
+ string mime = isDirectory ? _directoryMediaType.ToString() : MediaTypeMap.GetMediaType(fileSysInfo.Extension).ToString();
+ string unescapedHref = isDirectory ? fileSysInfo.Name + UriSegmentSeparator : fileSysInfo.Name;
+ long size = isDirectory ? 0 : ((FileInfoBase)fileSysInfo).Length;
+
+ yield return new VfsStatEntry
+ {
+ Name = fileSysInfo.Name,
+ MTime = fileSysInfo.LastWriteTimeUtc,
+ CRTime = fileSysInfo.CreationTimeUtc,
+ Mime = mime,
+ Size = size,
+ Href = (baseAddress + Uri.EscapeUriString(unescapedHref) + query).EscapeHashCharacter(),
+ Path = fileSysInfo.FullName
+ };
+ }
+
+ // add special folders when requesting Root url
+ // TODO: ahmels
+ //var routeData = request.HttpContext.GetRouteData();
+ //if (routeData != null && string.IsNullOrEmpty(routeData.Values["path"] as string))
+ //{
+ // foreach (var entry in VfsSpecialFolders.GetEntries(baseAddress, query))
+ // {
+ // yield return entry;
+ // }
+ //}
+ }
+
+ protected HttpResponseMessage CreateResponse(HttpStatusCode statusCode, object payload = null)
+ {
+ var response = new HttpResponseMessage(statusCode);
+ if (payload != null)
+ {
+ var content = payload is string ? payload as string : JsonConvert.SerializeObject(payload);
+ response.Content = new StringContent(content, Encoding.UTF8, "application/json");
+ }
+ return response;
+ }
+ }
+}
diff --git a/src/WebJobs.Script.WebHost/Management/WebFunctionsManager.cs b/src/WebJobs.Script.WebHost/Management/WebFunctionsManager.cs
new file mode 100755
index 0000000000..18a64c8f75
--- /dev/null
+++ b/src/WebJobs.Script.WebHost/Management/WebFunctionsManager.cs
@@ -0,0 +1,163 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Azure.WebJobs.Script;
+using Microsoft.Azure.WebJobs.Script.Management.Models;
+using Microsoft.Azure.WebJobs.Script.WebHost;
+using Microsoft.Azure.WebJobs.Script.WebHost.Extensions;
+using Microsoft.Azure.WebJobs.Script.WebHost.Helpers;
+using Microsoft.Extensions.Logging;
+using Newtonsoft.Json;
+
+namespace Microsoft.Azure.WebJobs.Script.WebHost.Management
+{
+ public class WebFunctionsManager : IWebFunctionsManager
+ {
+ private readonly ScriptHostConfiguration _config;
+ private readonly ILogger _logger;
+
+ public WebFunctionsManager(WebHostSettings webSettings, ILoggerFactory loggerFactory)
+ {
+ _config = WebHostResolver.CreateScriptHostConfiguration(webSettings);
+ _logger = loggerFactory?.CreateLogger(ScriptConstants.LogCategoryKeysController);
+ }
+
+ ///
+ /// Calls into ScriptHost to retrieve list of FunctionMetadata
+ /// and maps them to FunctionMetadataResponse.
+ ///
+ /// Current HttpRequest for figuring out baseUrl
+ /// collection of FunctionMetadataResponse
+ public async Task> GetFunctionsMetadata(HttpRequest request)
+ {
+ return await ScriptHost.ReadFunctionsMetadata(FileUtility.EnumerateDirectories(_config.RootScriptPath), _logger, new Dictionary>())
+ .Select(fm => fm.ToFunctionMetadataResponse(request, _config))
+ .WhenAll();
+ }
+
+ ///
+ /// It handles creating a new function or updating an existing one.
+ /// It attempts to clean left over artifacts from a possible previous function with the same name
+ /// if config is changed, then `configChanged` is set to true so the caller can call SyncTriggers if needed.
+ ///
+ /// name of the function to be created
+ /// in case of update for function.json
+ /// Current HttpRequest.
+ /// (success, configChanged, functionMetadataResult)
+ public async Task<(bool, bool, FunctionMetadataResponse)> CreateOrUpdate(string name, FunctionMetadataResponse functionMetadata, HttpRequest request)
+ {
+ var configChanged = false;
+ var functionDir = Path.Combine(_config.RootScriptPath, name);
+
+ // Make sure the function folder exists
+ if (!FileUtility.DirectoryExists(functionDir))
+ {
+ // Cleanup any leftover artifacts from a function with the same name before.
+ DeleteFunctionArtifacts(functionMetadata);
+ Directory.CreateDirectory(functionDir);
+ }
+
+ string newConfig = null;
+ string configPath = Path.Combine(functionDir, ScriptConstants.FunctionMetadataFileName);
+ string dataFilePath = FunctionMetadataExtensions.GetTestDataFilePath(name, _config);
+
+ // If files are included, write them out
+ if (functionMetadata?.Files != null)
+ {
+ // If the config is passed in the file collection, save it and don't process it as a file
+ if (functionMetadata.Files.TryGetValue(ScriptConstants.FunctionMetadataFileName, out newConfig))
+ {
+ functionMetadata.Files.Remove(ScriptConstants.FunctionMetadataFileName);
+ }
+
+ // Delete all existing files in the directory. This will also delete current function.json, but it gets recreated below
+ FileUtility.DeleteDirectoryContentsSafe(functionDir);
+
+ await functionMetadata
+ .Files
+ .Select(e => FileUtility.WriteAsync(Path.Combine(functionDir, e.Key), e.Value))
+ .WhenAll();
+ }
+
+ // Get the config (if it was not already passed in as a file)
+ if (newConfig == null && functionMetadata?.Config != null)
+ {
+ newConfig = JsonConvert.SerializeObject(functionMetadata?.Config, Formatting.Indented);
+ }
+
+ // Get the current config, if any
+ string currentConfig = null;
+ if (FileUtility.FileExists(configPath))
+ {
+ currentConfig = await FileUtility.ReadAsync(configPath);
+ }
+
+ // Save the file and set changed flag is it has changed. This helps optimize the syncTriggers call
+ if (newConfig != currentConfig)
+ {
+ await FileUtility.WriteAsync(configPath, newConfig);
+ configChanged = true;
+ }
+
+ if (functionMetadata.TestData != null)
+ {
+ await FileUtility.WriteAsync(dataFilePath, functionMetadata.TestData);
+ }
+
+ (var success, var functionMetadataResult) = await TryGetFunction(name, request); // test_data took from incoming request, it will not exceed the limit
+ return (success, configChanged, functionMetadataResult);
+ }
+
+ ///
+ /// maps a functionName to its FunctionMetadataResponse
+ ///
+ /// Function name to retrieve
+ /// Current HttpRequest
+ /// (success, FunctionMetadataResponse)
+ public async Task<(bool, FunctionMetadataResponse)> TryGetFunction(string name, HttpRequest request)
+ {
+ var functionMetadata = ScriptHost.ReadFunctionMetadata(Path.Combine(_config.RootScriptPath, name), _logger, new Dictionary>());
+ if (functionMetadata != null)
+ {
+ return (true, await functionMetadata.ToFunctionMetadataResponse(request, _config));
+ }
+ else
+ {
+ return (false, null);
+ }
+ }
+
+ ///
+ /// Delete a function and all it's artifacts.
+ ///
+ /// Function to be deleted
+ /// (success, errorMessage)
+ public (bool, string) TryDeleteFunction(FunctionMetadataResponse function)
+ {
+ try
+ {
+ FileUtility.DeleteDirectoryContentsSafe(function.GetFunctionPath(_config));
+ DeleteFunctionArtifacts(function);
+ return (true, string.Empty);
+ }
+ catch (Exception e)
+ {
+ return (false, e.ToString());
+ }
+ }
+
+ private void DeleteFunctionArtifacts(FunctionMetadataResponse function)
+ {
+ // TODO: clear secrets
+ // TODO: clear logs
+ FileUtility.DeleteFileSafe(function.GetFunctionTestDataFilePath(_config));
+ }
+ }
+}
diff --git a/src/WebJobs.Script.WebHost/Middleware/VirtualFileSystemMiddleware.cs b/src/WebJobs.Script.WebHost/Middleware/VirtualFileSystemMiddleware.cs
new file mode 100755
index 0000000000..cd4164864d
--- /dev/null
+++ b/src/WebJobs.Script.WebHost/Middleware/VirtualFileSystemMiddleware.cs
@@ -0,0 +1,115 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+
+using System;
+using System.Net.Http;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Authorization.Policy;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Azure.WebJobs.Script.WebHost.Extensions;
+using Microsoft.Azure.WebJobs.Script.WebHost.Management;
+using Microsoft.Azure.WebJobs.Script.WebHost.Security.Authorization.Policies;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Microsoft.Azure.WebJobs.Script.WebHost.Middleware
+{
+ public class VirtualFileSystemMiddleware : IMiddleware
+ {
+ private readonly VirtualFileSystem _vfs;
+
+ public VirtualFileSystemMiddleware(VirtualFileSystem vfs)
+ {
+ _vfs = vfs;
+ }
+
+ ///
+ /// A request is a vfs request if it starts with /admin/zip or /admin/vfs
+ ///
+ /// Current HttpContext
+ /// IsVirtualFileSystemRequest
+ public static bool IsVirtualFileSystemRequest(HttpContext context)
+ {
+ return context.Request.Path.StartsWithSegments("/admin/vfs");
+ }
+
+ public async Task InvokeAsync(HttpContext context, RequestDelegate _)
+ {
+ var authorized = await AuthenticateAndAuthorize(context);
+
+ if (!authorized)
+ {
+ context.Response.StatusCode = StatusCodes.Status401Unauthorized;
+ }
+ else
+ {
+ await InternalInvokeAsync(context);
+ }
+ }
+
+ private async Task InternalInvokeAsync(HttpContext context)
+ {
+ // choose the right instance to use.
+ HttpResponseMessage response = null;
+ try
+ {
+ switch (context.Request.Method.ToLowerInvariant())
+ {
+ case "get":
+ response = await _vfs.GetItem(context.Request);
+ break;
+
+ case "put":
+ response = await _vfs.PutItem(context.Request);
+ break;
+
+ case "delete":
+ response = await _vfs.DeleteItem(context.Request);
+ break;
+
+ default:
+ // VFS only supports GET, PUT, and DELETE
+ response = new HttpResponseMessage(System.Net.HttpStatusCode.MethodNotAllowed);
+ break;
+ }
+
+ context.Response.StatusCode = (int)response.StatusCode;
+
+ // write response headers
+ context.Response.Headers.AddRange(response.Headers.ToCoreHeaders());
+
+ // This is to handle NullContent which != null, but has ContentLength of null.
+ if (response.Content != null && response.Content.Headers.ContentLength != null)
+ {
+ // Exclude content length to let ASP.NET Core take care of setting that based on the stream size.
+ context.Response.Headers.AddRange(response.Content.Headers.ToCoreHeaders("Content-Length"));
+ await response.Content.CopyToAsync(context.Response.Body);
+ }
+ response.Dispose();
+ }
+ catch (Exception e)
+ {
+ if (response != null)
+ {
+ response.Dispose();
+ }
+
+ await context.Response.WriteAsync(e.Message);
+ }
+ }
+
+ private async Task AuthenticateAndAuthorize(HttpContext context)
+ {
+ var authorizationPolicyProvider = context.RequestServices.GetRequiredService();
+ var policyEvaluator = context.RequestServices.GetRequiredService();
+
+ var policy = await authorizationPolicyProvider.GetPolicyAsync(PolicyNames.AdminAuthLevel);
+ var authenticateResult = await policyEvaluator.AuthenticateAsync(policy, context);
+
+ // For admin, resource is null.
+ var authorizeResult = await policyEvaluator.AuthorizeAsync(policy, authenticateResult, context, resource: null);
+
+ return authorizeResult.Succeeded;
+ }
+ }
+}
diff --git a/src/WebJobs.Script.WebHost/Models/FunctionMetadataResponse.cs b/src/WebJobs.Script.WebHost/Models/FunctionMetadataResponse.cs
new file mode 100755
index 0000000000..6135d76b95
--- /dev/null
+++ b/src/WebJobs.Script.WebHost/Models/FunctionMetadataResponse.cs
@@ -0,0 +1,87 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+
+namespace Microsoft.Azure.WebJobs.Script.Management.Models
+{
+ public class FunctionMetadataResponse
+ {
+ ///
+ /// Gets or sets the function name
+ ///
+ [JsonProperty(PropertyName = "name")]
+ public string Name { get; set; }
+
+ ///
+ /// Gets or sets the script folder url
+ ///
+ [JsonProperty(PropertyName = "script_root_path_href")]
+ public Uri ScriptRootPathHref { get; set; }
+
+ ///
+ /// Gets or sets script file url
+ ///
+ [JsonProperty(PropertyName = "script_href")]
+ public Uri ScriptHref { get; set; }
+
+ ///
+ /// Gets or sets function config file url
+ ///
+ [JsonProperty(PropertyName = "config_href")]
+ public Uri ConfigHref { get; set; }
+
+ ///
+ /// Gets or sets function test data url
+ ///
+ [JsonProperty(PropertyName = "test_data_href")]
+ public Uri TestDataHref { get; set; }
+
+ ///
+ /// Gets or sets current function self link
+ ///
+ [JsonProperty(PropertyName = "href")]
+ public Uri Href { get; set; }
+
+ ///
+ /// Gets or sets function config json
+ ///
+ [JsonProperty(PropertyName = "config")]
+ public JObject Config { get; set; }
+
+ ///
+ /// Gets or sets flat list of files and their content.
+ /// The dictionary is fileName => fileContent
+ ///
+ [JsonProperty(PropertyName = "files")]
+ public IDictionary Files { get; set; }
+
+ ///
+ /// Gets or sets the test data string.
+ /// This is only used for the UI and only supports string inputs.
+ ///
+ [JsonProperty(PropertyName = "test_data")]
+ public string TestData { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the function is disabled
+ ///
+ [JsonProperty(PropertyName = "isDisabled")]
+ public bool IsDisabled { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the function is direct.
+ ///
+ [JsonProperty(PropertyName = "isDirect")]
+ public bool IsDirect { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the function is a proxy function.
+ ///
+ [JsonProperty(PropertyName = "isProxy")]
+ public bool IsProxy { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/src/WebJobs.Script.WebHost/Models/VfsStatEntry.cs b/src/WebJobs.Script.WebHost/Models/VfsStatEntry.cs
new file mode 100644
index 0000000000..b4a5688681
--- /dev/null
+++ b/src/WebJobs.Script.WebHost/Models/VfsStatEntry.cs
@@ -0,0 +1,36 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the MIT License. See License.txt in the project root for license information.
+
+using System;
+using Newtonsoft.Json;
+
+namespace Microsoft.Azure.WebJobs.Script.WebHost.Models
+{
+ ///
+ /// Represents a directory structure. Used by to browse
+ /// a Kudu file system or the git repository.
+ ///
+ public class VfsStatEntry
+ {
+ [JsonProperty(PropertyName = "name")]
+ public string Name { get; set; }
+
+ [JsonProperty(PropertyName = "size")]
+ public long Size { get; set; }
+
+ [JsonProperty(PropertyName = "mtime")]
+ public DateTimeOffset MTime { get; set; }
+
+ [JsonProperty(PropertyName = "crtime")]
+ public DateTimeOffset CRTime { get; set; }
+
+ [JsonProperty(PropertyName = "mime")]
+ public string Mime { get; set; }
+
+ [JsonProperty(PropertyName = "href")]
+ public string Href { get; set; }
+
+ [JsonProperty(PropertyName = "path")]
+ public string Path { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/src/WebJobs.Script.WebHost/WebHostResolver.cs b/src/WebJobs.Script.WebHost/WebHostResolver.cs
index a9f44c8681..c8a983adc0 100644
--- a/src/WebJobs.Script.WebHost/WebHostResolver.cs
+++ b/src/WebJobs.Script.WebHost/WebHostResolver.cs
@@ -117,7 +117,7 @@ internal void EnsureInitialized(WebHostSettings settings)
_activeHostManager = new WebScriptHostManager(_activeScriptHostConfig, _secretManagerFactory, _eventManager, _settingsManager, settings,
_router, loggerProviderFactory: _loggerProviderFactory, loggerFactory: _loggerFactory);
//_activeReceiverManager = new WebHookReceiverManager(_activeHostManager.SecretManager);
- InitializeFileSystem(_settingsManager.FileSystemIsReadOnly);
+ InitializeFileSystem(settings, _settingsManager.FileSystemIsReadOnly);
if (_standbyHostManager != null)
{
@@ -153,7 +153,7 @@ internal void EnsureInitialized(WebHostSettings settings)
_router, loggerProviderFactory: _loggerProviderFactory, loggerFactory: _loggerFactory);
// _standbyReceiverManager = new WebHookReceiverManager(_standbyHostManager.SecretManager);
- InitializeFileSystem(_settingsManager.FileSystemIsReadOnly);
+ InitializeFileSystem(settings, _settingsManager.FileSystemIsReadOnly);
StandbyManager.Initialize(_standbyScriptHostConfig, logger);
// start a background timer to identify when specialization happens
@@ -186,12 +186,13 @@ internal static WebHostSettings CreateStandbySettings(WebHostSettings settings)
internal static ScriptHostConfiguration CreateScriptHostConfiguration(WebHostSettings settings, bool inStandbyMode = false)
{
- var scriptHostConfig = new ScriptHostConfiguration
+ var scriptHostConfig = new ScriptHostConfiguration()
{
RootScriptPath = settings.ScriptPath,
RootLogPath = settings.LogPath,
FileLoggingMode = FileLoggingMode.DebugOnly,
- IsSelfHost = settings.IsSelfHost
+ IsSelfHost = settings.IsSelfHost,
+ TestDataPath = settings.TestDataPath
};
if (inStandbyMode)
@@ -245,10 +246,11 @@ private void OnSpecializationTimerTick(object state)
_activeHostManager?.RunAsync(CancellationToken.None);
}
- private static void InitializeFileSystem(bool readOnlyFileSystem)
+ private static void InitializeFileSystem(WebHostSettings settings, bool readOnlyFileSystem)
{
if (ScriptSettingsManager.Instance.IsAzureEnvironment)
{
+ // When running on Azure, we kick this off on the background
Task.Run(() =>
{
string home = ScriptSettingsManager.Instance.GetSetting(EnvironmentSettingNames.AzureWebsiteHomePath);
@@ -265,11 +267,14 @@ private static void InitializeFileSystem(bool readOnlyFileSystem)
}
}
- string toolsPath = Path.Combine(home, @"site\tools");
if (!readOnlyFileSystem)
{
+ string toolsPath = Path.Combine(home, @"site\tools");
// Create the tools folder if it doesn't exist
Directory.CreateDirectory(toolsPath);
+
+ // Create the test data folder
+ Directory.CreateDirectory(settings.TestDataPath);
}
var folders = new List();
@@ -288,6 +293,19 @@ private static void InitializeFileSystem(bool readOnlyFileSystem)
}
});
}
+ else
+ {
+ // Ensure we have our scripts directory in non-Azure scenarios
+ if (!string.IsNullOrEmpty(settings.ScriptPath))
+ {
+ Directory.CreateDirectory(settings.ScriptPath);
+ }
+
+ if (!string.IsNullOrEmpty(settings.TestDataPath))
+ {
+ Directory.CreateDirectory(settings.TestDataPath);
+ }
+ }
}
public void Dispose()
@@ -302,4 +320,4 @@ public void Dispose()
//_activeReceiverManager?.Dispose();
}
}
-}
\ No newline at end of file
+}
diff --git a/src/WebJobs.Script.WebHost/WebHostSettings.cs b/src/WebJobs.Script.WebHost/WebHostSettings.cs
index 3d4c276f2a..e42abae401 100644
--- a/src/WebJobs.Script.WebHost/WebHostSettings.cs
+++ b/src/WebJobs.Script.WebHost/WebHostSettings.cs
@@ -22,6 +22,13 @@ public class WebHostSettings
public string SecretsPath { get; set; }
+ ///
+ /// Gets or sets the path for storing test data
+ /// This is used for function management operations where the client (portal)
+ /// saves the last invocation test data for a given function
+ ///
+ public string TestDataPath { get; set; }
+
///
/// Gets or sets a value indicating whether authentication/authorization
/// should be disabled. Useful for local debugging or CLI scenarios.
@@ -40,12 +47,14 @@ internal static WebHostSettings CreateDefault(ScriptSettingsManager settingsMana
string home = settingsManager.GetSetting(EnvironmentSettingNames.AzureWebsiteHomePath);
settings.ScriptPath = Path.Combine(home, "site", "wwwroot");
settings.LogPath = Path.Combine(home, "LogFiles", "Application", "Functions");
- settings.SecretsPath = Path.Combine(home, @"data", "Functions", "secrets");
+ settings.SecretsPath = Path.Combine(home, "data", "Functions", "secrets");
+ settings.TestDataPath = Path.Combine(home, "data", "Functions", "sampledata");
}
else
{
settings.ScriptPath = settingsManager.GetSetting(EnvironmentSettingNames.AzureWebJobsScriptRoot);
settings.LogPath = Path.Combine(Path.GetTempPath(), @"Functions");
+ settings.TestDataPath = Path.Combine(Path.GetTempPath(), @"FunctionsData");
// TODO: Revisit. We'll likely have to take an instance of an IHostingEnvironment here
settings.SecretsPath = Path.Combine(AppContext.BaseDirectory, "Secrets");
diff --git a/src/WebJobs.Script.WebHost/WebJobsApplicationBuilderExtension.cs b/src/WebJobs.Script.WebHost/WebJobsApplicationBuilderExtension.cs
index dab136f3a6..d87720d66a 100644
--- a/src/WebJobs.Script.WebHost/WebJobsApplicationBuilderExtension.cs
+++ b/src/WebJobs.Script.WebHost/WebJobsApplicationBuilderExtension.cs
@@ -1,4 +1,4 @@
-// Copyright (c) .NET Foundation. All rights reserved.
+// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.
using System;
@@ -27,6 +27,9 @@ public static IApplicationBuilder UseWebJobsScriptHost(this IApplicationBuilder
config.UseMiddleware();
});
+ // Register /admin/vfs, and /admin/zip to the VirtualFileSystem middleware.
+ builder.UseWhen(VirtualFileSystemMiddleware.IsVirtualFileSystemRequest, config => config.UseMiddleware());
+
// Ensure the HTTP binding routing is registered after all middleware
builder.UseHttpBindingRouting(applicationLifetime, routes);
@@ -40,4 +43,4 @@ public static IApplicationBuilder UseWebJobsScriptHost(this IApplicationBuilder
return builder;
}
}
-}
+}
\ No newline at end of file
diff --git a/src/WebJobs.Script.WebHost/WebJobsServiceCollectionExtensions.cs b/src/WebJobs.Script.WebHost/WebJobsServiceCollectionExtensions.cs
index e61ca77700..c23dbc56c4 100644
--- a/src/WebJobs.Script.WebHost/WebJobsServiceCollectionExtensions.cs
+++ b/src/WebJobs.Script.WebHost/WebJobsServiceCollectionExtensions.cs
@@ -13,6 +13,8 @@
using Microsoft.Azure.WebJobs.Script.Diagnostics;
using Microsoft.Azure.WebJobs.Script.Eventing;
using Microsoft.Azure.WebJobs.Script.WebHost.Diagnostics;
+using Microsoft.Azure.WebJobs.Script.WebHost.Management;
+using Microsoft.Azure.WebJobs.Script.WebHost.Middleware;
using Microsoft.Azure.WebJobs.Script.WebHost.Security.Authorization;
using Microsoft.Azure.WebJobs.Script.WebHost.Security.Authorization.Policies;
using Microsoft.Extensions.Configuration;
@@ -97,6 +99,9 @@ public static IServiceProvider AddWebJobsScriptHost(this IServiceCollection serv
// The services below need to be scoped to a pseudo-tenant (warm/specialized environment)
builder.Register(c => c.Resolve().GetWebScriptHostManager()).ExternallyOwned();
builder.Register(c => c.Resolve().GetSecretManager()).ExternallyOwned();
+ builder.RegisterType().As().SingleInstance();
+ builder.RegisterType();
+ builder.RegisterType();
// Populate the container builder with registered services.
// Doing this here will cause any services registered in the service collection to
@@ -127,4 +132,4 @@ private static ILoggerFactory CreateLoggerFactory(string hostInstanceId, ScriptS
return loggerFactory;
}
}
-}
+}
\ No newline at end of file
diff --git a/src/WebJobs.Script/Config/ScriptHostConfiguration.cs b/src/WebJobs.Script/Config/ScriptHostConfiguration.cs
index 6b590bd541..93b78d5ba9 100644
--- a/src/WebJobs.Script/Config/ScriptHostConfiguration.cs
+++ b/src/WebJobs.Script/Config/ScriptHostConfiguration.cs
@@ -19,6 +19,7 @@ public ScriptHostConfiguration()
FileLoggingMode = FileLoggingMode.Never;
RootScriptPath = Environment.CurrentDirectory;
RootLogPath = Path.Combine(Path.GetTempPath(), "Functions");
+ TestDataPath = Path.Combine(Path.GetTempPath(), "FunctionsData");
LogFilter = new LogCategoryFilter();
HostHealthMonitor = new HostHealthMonitorConfiguration();
}
@@ -38,6 +39,11 @@ public ScriptHostConfiguration()
///
public string RootLogPath { get; set; }
+ ///
+ /// Gets or sets the root path for sample test data.
+ ///
+ public string TestDataPath { get; set; }
+
///
/// Gets or sets a value indicating whether the should
/// monitor file for changes (default is true). When set to true, the host will
diff --git a/src/WebJobs.Script/Extensions/FileUtility.cs b/src/WebJobs.Script/Extensions/FileUtility.cs
index 88c48285f6..fa9505ebf1 100644
--- a/src/WebJobs.Script/Extensions/FileUtility.cs
+++ b/src/WebJobs.Script/Extensions/FileUtility.cs
@@ -2,7 +2,9 @@
// Licensed under the MIT License. See License.txt in the project root for license information.
using System;
+using System.Collections.Generic;
using System.IO;
+using System.IO.Abstractions;
using System.Text;
using System.Threading.Tasks;
@@ -10,11 +12,20 @@ namespace Microsoft.Azure.WebJobs.Script
{
public static class FileUtility
{
+ private static IFileSystem _default = new FileSystem();
+ private static IFileSystem _instance;
+
+ public static IFileSystem Instance
+ {
+ get { return _instance ?? _default; }
+ set { _instance = value; }
+ }
+
public static void EnsureDirectoryExists(string path)
{
- if (!Directory.Exists(path))
+ if (!Instance.Directory.Exists(path))
{
- Directory.CreateDirectory(path);
+ Instance.Directory.CreateDirectory(path);
}
}
@@ -22,9 +33,9 @@ public static Task DeleteDirectoryAsync(string path, bool recursive)
{
return Task.Run(() =>
{
- if (Directory.Exists(path))
+ if (Instance.Directory.Exists(path))
{
- Directory.Delete(path, recursive);
+ Instance.Directory.Delete(path, recursive);
}
});
}
@@ -33,9 +44,9 @@ public static Task DeleteIfExistsAsync(string path)
{
return Task.Run(() =>
{
- if (File.Exists(path))
+ if (Instance.File.Exists(path))
{
- File.Delete(path);
+ Instance.File.Delete(path);
return true;
}
return false;
@@ -55,7 +66,8 @@ public static async Task WriteAsync(string path, string contents, Encoding encod
}
encoding = encoding ?? Encoding.UTF8;
- using (var writer = new StreamWriter(path, false, encoding, 4096))
+ using (Stream fileStream = OpenFile(path, FileMode.Create, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete))
+ using (var writer = new StreamWriter(fileStream, encoding, 4096))
{
await writer.WriteAsync(contents);
}
@@ -69,12 +81,18 @@ public static async Task ReadAsync(string path, Encoding encoding = null
}
encoding = encoding ?? Encoding.UTF8;
- using (var reader = new StreamReader(path, encoding, true, 4096))
+ using (var fileStream = OpenFile(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete))
+ using (var reader = new StreamReader(fileStream, encoding, true, 4096))
{
return await reader.ReadToEndAsync();
}
}
+ public static Stream OpenFile(string path, FileMode mode, FileAccess access = FileAccess.ReadWrite, FileShare share = FileShare.None)
+ {
+ return Instance.File.Open(path, mode, access, share);
+ }
+
public static string GetRelativePath(string path1, string path2)
{
if (path1 == null)
@@ -124,20 +142,107 @@ public static Task GetFilesAsync(string path, string prefix)
return Task.Run(() =>
{
- return Directory.GetFiles(path, prefix);
+ return Instance.Directory.GetFiles(path, prefix);
});
}
public static void CopyDirectory(string sourcePath, string targetPath)
{
- foreach (string dirPath in Directory.GetDirectories(sourcePath, "*", SearchOption.AllDirectories))
+ foreach (string dirPath in Instance.Directory.GetDirectories(sourcePath, "*", SearchOption.AllDirectories))
+ {
+ Instance.Directory.CreateDirectory(dirPath.Replace(sourcePath, targetPath));
+ }
+
+ foreach (string filePath in Instance.Directory.GetFiles(sourcePath, "*.*", SearchOption.AllDirectories))
+ {
+ Instance.File.Copy(filePath, filePath.Replace(sourcePath, targetPath), true);
+ }
+ }
+
+ public static bool FileExists(string path) => Instance.File.Exists(path);
+
+ public static bool DirectoryExists(string path) => Instance.Directory.Exists(path);
+
+ public static DirectoryInfoBase DirectoryInfoFromDirectoryName(string localSiteRootPath) => Instance.DirectoryInfo.FromDirectoryName(localSiteRootPath);
+
+ public static FileInfoBase FileInfoFromFileName(string localFilePath) => Instance.FileInfo.FromFileName(localFilePath);
+
+ public static string GetFullPath(string path) => Instance.Path.GetFullPath(path);
+
+ private static void DeleteDirectoryContentsSafe(DirectoryInfoBase directoryInfo, bool ignoreErrors)
+ {
+ try
+ {
+ if (directoryInfo.Exists)
+ {
+ foreach (var fsi in directoryInfo.GetFileSystemInfos())
+ {
+ DeleteFileSystemInfo(fsi, ignoreErrors);
+ }
+ }
+ }
+ catch when (ignoreErrors)
{
- Directory.CreateDirectory(dirPath.Replace(sourcePath, targetPath));
}
+ }
- foreach (string filePath in Directory.GetFiles(sourcePath, "*.*", SearchOption.AllDirectories))
+ private static void DeleteFileSystemInfo(FileSystemInfoBase fileSystemInfo, bool ignoreErrors)
+ {
+ if (!fileSystemInfo.Exists)
+ {
+ return;
+ }
+
+ try
+ {
+ fileSystemInfo.Attributes = FileAttributes.Normal;
+ }
+ catch when (ignoreErrors)
+ {
+ }
+
+ if (fileSystemInfo is DirectoryInfoBase directoryInfo)
+ {
+ DeleteDirectoryContentsSafe(directoryInfo, ignoreErrors);
+ }
+
+ DoSafeAction(fileSystemInfo.Delete, ignoreErrors);
+ }
+
+ public static void DeleteDirectoryContentsSafe(string path, bool ignoreErrors = true)
+ {
+ try
+ {
+ var directoryInfo = DirectoryInfoFromDirectoryName(path);
+ if (directoryInfo.Exists)
+ {
+ foreach (var fsi in directoryInfo.GetFileSystemInfos())
+ {
+ DeleteFileSystemInfo(fsi, ignoreErrors);
+ }
+ }
+ }
+ catch when (ignoreErrors)
+ {
+ }
+ }
+
+ public static void DeleteFileSafe(string path)
+ {
+ var info = FileInfoFromFileName(path);
+ DeleteFileSystemInfo(info, ignoreErrors: true);
+ }
+
+ public static IEnumerable EnumerateDirectories(string path) => Instance.Directory.EnumerateDirectories(path);
+
+ private static void DoSafeAction(Action action, bool ignoreErrors)
+ {
+ try
+ {
+ action();
+ }
+ catch when (ignoreErrors)
{
- File.Copy(filePath, filePath.Replace(sourcePath, targetPath), true);
}
}
}
diff --git a/src/WebJobs.Script/Extensions/HttpRequestExtensions.cs b/src/WebJobs.Script/Extensions/HttpRequestExtensions.cs
index 08fa3edd78..56407d8f1d 100644
--- a/src/WebJobs.Script/Extensions/HttpRequestExtensions.cs
+++ b/src/WebJobs.Script/Extensions/HttpRequestExtensions.cs
@@ -1,8 +1,11 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.
+using System;
+using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Azure.WebJobs.Script.Config;
using Microsoft.Extensions.Primitives;
@@ -68,5 +71,6 @@ public static bool IsColdStart(this HttpRequest request)
{
return !string.IsNullOrEmpty(request.GetHeaderValueOrDefault(ScriptConstants.AntaresColdStartHeaderName));
}
+ public static Uri GetRequestUri(this HttpRequest request) => new Uri(request.GetDisplayUrl());
}
}
diff --git a/src/WebJobs.Script/Host/ScriptHost.cs b/src/WebJobs.Script/Host/ScriptHost.cs
index 02725e59fa..24ad9854fc 100644
--- a/src/WebJobs.Script/Host/ScriptHost.cs
+++ b/src/WebJobs.Script/Host/ScriptHost.cs
@@ -543,7 +543,7 @@ private Collection LoadFunctionMetadata()
Collection functionMetadata;
using (_metricsLogger.LatencyEvent(MetricEventNames.HostStartupReadFunctionMetadataLatency))
{
- functionMetadata = ReadFunctionMetadata(_directorySnapshot, _startupLogger, FunctionErrors, _settingsManager, ScriptConfig.Functions);
+ functionMetadata = ReadFunctionsMetadata(_directorySnapshot, _startupLogger, FunctionErrors, _settingsManager, ScriptConfig.Functions);
_startupLogger.LogTrace("Function metadata read.");
}
@@ -1044,7 +1044,7 @@ private static FunctionMetadata ParseFunctionMetadata(string functionName, JObje
return functionMetadata;
}
- public static Collection ReadFunctionMetadata(IEnumerable functionDirectories, ILogger logger, Dictionary> functionErrors, ScriptSettingsManager settingsManager = null, IEnumerable functionWhitelist = null)
+ public static Collection ReadFunctionsMetadata(IEnumerable functionDirectories, ILogger logger, Dictionary> functionErrors, ScriptSettingsManager settingsManager = null, IEnumerable functionWhitelist = null)
{
var functions = new Collection();
settingsManager = settingsManager ?? ScriptSettingsManager.Instance;
@@ -1056,58 +1056,69 @@ public static Collection ReadFunctionMetadata(IEnumerable> functionErrors, ScriptSettingsManager settingsManager = null, IEnumerable functionWhitelist = null)
+ {
+ string functionName = null;
+
+ try
+ {
+ // read the function config
+ // read the function config
+ string functionConfigPath = Path.Combine(scriptDir, ScriptConstants.FunctionMetadataFileName);
+ string json = null;
try
{
- // read the function config
- string functionConfigPath = Path.Combine(scriptDir, ScriptConstants.FunctionMetadataFileName);
- string json = null;
- try
- {
- json = File.ReadAllText(functionConfigPath);
- }
- catch (FileNotFoundException)
- {
- // not a function directory
- continue;
- }
+ json = File.ReadAllText(functionConfigPath);
+ }
+ catch (FileNotFoundException)
+ {
+ // not a function directory
+ return null;
+ }
- functionName = Path.GetFileName(scriptDir);
- if (functionWhitelist != null &&
- !functionWhitelist.Contains(functionName, StringComparer.OrdinalIgnoreCase))
- {
- // a functions filter has been specified and the current function is
- // not in the filter list
- continue;
- }
+ functionName = Path.GetFileName(scriptDir);
+ if (functionWhitelist != null &&
+ !functionWhitelist.Contains(functionName, StringComparer.OrdinalIgnoreCase))
+ {
+ // a functions filter has been specified and the current function is
+ // not in the filter list
+ return null;
+ }
- ValidateName(functionName);
+ ValidateName(functionName);
- JObject functionConfig = JObject.Parse(json);
+ JObject functionConfig = JObject.Parse(json);
- string functionError = null;
- FunctionMetadata functionMetadata = null;
- if (!TryParseFunctionMetadata(functionName, functionConfig, logger, scriptDir, settingsManager, out functionMetadata, out functionError))
- {
- // for functions in error, log the error and don't
- // add to the functions collection
- AddFunctionError(functionErrors, functionName, functionError);
- continue;
- }
- else if (functionMetadata != null)
- {
- functions.Add(functionMetadata);
- }
+ string functionError = null;
+ FunctionMetadata functionMetadata = null;
+ if (!TryParseFunctionMetadata(functionName, functionConfig, logger, scriptDir, settingsManager, out functionMetadata, out functionError))
+ {
+ // for functions in error, log the error and don't
+ // add to the functions collection
+ AddFunctionError(functionErrors, functionName, functionError);
+ return null;
}
- catch (Exception ex)
+ else if (functionMetadata != null)
{
- // log any unhandled exceptions and continue
- AddFunctionError(functionErrors, functionName, Utility.FlattenException(ex, includeSource: false), isFunctionShortName: true);
+ return functionMetadata;
}
}
-
- return functions;
+ catch (Exception ex)
+ {
+ // log any unhandled exceptions and continue
+ AddFunctionError(functionErrors, functionName, Utility.FlattenException(ex, includeSource: false), isFunctionShortName: true);
+ }
+ return null;
}
internal Collection ReadProxyMetadata(ScriptHostConfiguration config, ScriptSettingsManager settingsManager = null)
@@ -1898,4 +1909,4 @@ protected override void Dispose(bool disposing)
base.Dispose(disposing);
}
}
-}
\ No newline at end of file
+}
diff --git a/src/WebJobs.Script/ScriptConstants.cs b/src/WebJobs.Script/ScriptConstants.cs
index abbfb021c0..c121214561 100644
--- a/src/WebJobs.Script/ScriptConstants.cs
+++ b/src/WebJobs.Script/ScriptConstants.cs
@@ -37,7 +37,8 @@ public static class ScriptConstants
public const string LoggerHttpRequest = "MS_HttpRequest";
- public const string LogCategoryAdminController = "Host.Controllers.Admin";
+ public const string LogCategoryHostController = "Host.Controllers.Host";
+ public const string LogCategoryFunctionsController = "Host.Controllers.Functions";
public const string LogCategoryKeysController = "Host.Controllers.Keys";
public const string LogCategoryHostGeneral = "Host.General";
public const string LogCategoryHostMetrics = "Host.Metrics";
diff --git a/src/WebJobs.Script/Utility.cs b/src/WebJobs.Script/Utility.cs
index 6272fef6ed..e818cf29cf 100644
--- a/src/WebJobs.Script/Utility.cs
+++ b/src/WebJobs.Script/Utility.cs
@@ -182,7 +182,7 @@ internal static string GetDefaultHostId(ScriptSettingsManager settingsManager, S
.Aggregate(new StringBuilder(), (b, c) => b.Append(c)).ToString();
hostId = $"{sanitizedMachineName}-{Math.Abs(GetStableHash(scriptConfig.RootScriptPath))}";
}
- else if (!string.IsNullOrEmpty(settingsManager.AzureWebsiteUniqueSlotName))
+ else if (!string.IsNullOrEmpty(settingsManager?.AzureWebsiteUniqueSlotName))
{
// If running on Azure Web App, derive the host ID from unique site slot name
hostId = settingsManager.AzureWebsiteUniqueSlotName;
diff --git a/test/WebJobs.Script.Tests/Controllers/Admin/AdminControllerTests.cs b/test/WebJobs.Script.Tests/Controllers/Admin/AdminControllerTests.cs
index 682465e070..22e8a5563d 100644
--- a/test/WebJobs.Script.Tests/Controllers/Admin/AdminControllerTests.cs
+++ b/test/WebJobs.Script.Tests/Controllers/Admin/AdminControllerTests.cs
@@ -14,6 +14,8 @@
using Microsoft.Azure.WebJobs.Script.Eventing;
using Microsoft.Azure.WebJobs.Script.WebHost;
using Microsoft.Azure.WebJobs.Script.WebHost.Controllers;
+using Microsoft.Azure.WebJobs.Script.WebHost.Filters;
+using Microsoft.Azure.WebJobs.Script.WebHost.Management;
using Microsoft.Azure.WebJobs.Script.WebHost.Models;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
@@ -30,7 +32,7 @@ public class AdminControllerTests : IDisposable
private Mock hostMock;
private Mock managerMock;
private Collection testFunctions;
- private AdminController testController;
+ private FunctionsController testController;
private Mock secretsManagerMock;
public AdminControllerTests()
@@ -42,6 +44,7 @@ public AdminControllerTests()
var environment = new NullScriptHostEnvironment();
var eventManager = new Mock();
var mockRouter = new Mock();
+ var mockWebFunctionManager = new Mock();
hostMock = new Mock(MockBehavior.Strict, new object[] { environment, eventManager.Object, config, null, null, null });
hostMock.Setup(p => p.Functions).Returns(testFunctions);
@@ -51,7 +54,7 @@ public AdminControllerTests()
managerMock = new Mock(MockBehavior.Strict, new object[] { config, new TestSecretManagerFactory(secretsManagerMock.Object), eventManager.Object, _settingsManager, settings, mockRouter.Object, NullLoggerFactory.Instance });
managerMock.SetupGet(p => p.Instance).Returns(hostMock.Object);
- testController = new AdminController(managerMock.Object, settings, new LoggerFactory(), null);
+ testController = new FunctionsController(mockWebFunctionManager.Object, managerMock.Object, new LoggerFactory());
}
[Fact]
diff --git a/test/WebJobs.Script.Tests/Managment/VirtualFileSystemFacts.cs b/test/WebJobs.Script.Tests/Managment/VirtualFileSystemFacts.cs
new file mode 100644
index 0000000000..818f99e8b9
--- /dev/null
+++ b/test/WebJobs.Script.Tests/Managment/VirtualFileSystemFacts.cs
@@ -0,0 +1,233 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Abstractions;
+using System.Net;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Text;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.Extensions;
+using Microsoft.Azure.WebJobs.Script.WebHost;
+using Microsoft.Azure.WebJobs.Script.WebHost.Helpers;
+using Microsoft.Azure.WebJobs.Script.WebHost.Management;
+using Microsoft.Extensions.Primitives;
+using Moq;
+using Xunit;
+
+namespace Microsoft.Azure.WebJobs.Script.Tests.Managment
+{
+ public class VirtualFileSystemFacts : IDisposable
+ {
+ private const string SiteRootPath = @"C:\DWASFiles\Sites\SiteName\VirtualDirectory0";
+ private static readonly string LocalSiteRootPath = Path.GetFullPath(Path.Combine(SiteRootPath, @".."));
+
+ [Fact]
+ public async Task DeleteRequestReturnsNotFoundIfItemDoesNotExist()
+ {
+ // Arrange
+ string path = @"/foo/bar/";
+ var fileInfo = new Mock();
+ fileInfo.SetupGet(f => f.Attributes).Returns((FileAttributes)(-1));
+ var dirInfo = new Mock();
+ dirInfo.SetupGet(d => d.Attributes).Returns((FileAttributes)(-1));
+ var fileSystem = CreateFileSystem(path, dirInfo.Object, fileInfo.Object);
+ var controller = CreateVirtualFileSystem();
+ FileUtility.Instance = fileSystem;
+
+ // Act
+ var result = await controller.DeleteItem(CreateRequest(path));
+
+ // Assert
+ Assert.Equal(HttpStatusCode.NotFound, result.StatusCode);
+ }
+
+ [Fact]
+ public async Task DeleteRequestDoesNotRecursivelyDeleteDirectoriesByDefault()
+ {
+ // Arrange
+ string path = @"/foo/bar/";
+ var fileInfo = new Mock();
+ fileInfo.SetupGet(f => f.Attributes).Returns(FileAttributes.Directory);
+ var dirInfo = new Mock();
+ dirInfo.SetupGet(d => d.Attributes).Returns(FileAttributes.Directory);
+ var fileSystem = CreateFileSystem(path, dirInfo.Object, fileInfo.Object);
+ FileUtility.Instance = fileSystem;
+
+ var controller = CreateVirtualFileSystem();
+
+ // Act
+ await controller.DeleteItem(CreateRequest(path));
+
+ // Assert
+ dirInfo.Verify(d => d.Delete(false), Times.Once());
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task DeleteRequestInvokesRecursiveDeleteBasedOnParameter(bool recursive)
+ {
+ // Arrange
+ string path = @"/foo/bar/";
+ var fileInfo = new Mock();
+ fileInfo.SetupGet(f => f.Attributes).Returns(FileAttributes.Directory);
+ var dirInfo = new Mock();
+ dirInfo.SetupGet(d => d.Attributes).Returns(FileAttributes.Directory);
+ var fileSystem = CreateFileSystem(path, dirInfo.Object, fileInfo.Object);
+ var controller = CreateVirtualFileSystem();
+ FileUtility.Instance = fileSystem;
+
+ // Act
+ var response = await controller.DeleteItem(CreateRequest(path), recursive);
+
+ // Assert
+ dirInfo.Verify(d => d.Delete(recursive), Times.Once());
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task DeleteItemReturnsPreconditionFailedResponseIfFileDeleteDoesNotContainETag()
+ {
+ // Arrange
+ string path = @"/foo/bar.txt";
+ var fileInfo = new Mock();
+ fileInfo.SetupGet(f => f.Attributes).Returns(FileAttributes.Normal);
+ var dirInfo = new Mock();
+ dirInfo.SetupGet(d => d.Attributes).Returns(FileAttributes.Normal);
+ var fileSystem = CreateFileSystem(path, dirInfo.Object, fileInfo.Object);
+ FileUtility.Instance = fileSystem;
+
+ var controller = CreateVirtualFileSystem();
+
+ // Act
+ var response = await controller.DeleteItem(CreateRequest(path));
+
+ // Assert
+ Assert.Equal(HttpStatusCode.PreconditionFailed, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task DeleteItemReturnsPreconditionFailedIfETagDoesNotMatch()
+ {
+ // Arrange
+ var date = new DateTime(2012, 07, 06);
+ string path = @"/foo/bar.txt";
+ var fileInfo = new Mock();
+ fileInfo.SetupGet(f => f.Attributes).Returns(FileAttributes.Normal);
+ fileInfo.SetupGet(f => f.LastWriteTimeUtc).Returns(date);
+ var dirInfo = new Mock();
+ dirInfo.SetupGet(d => d.Attributes).Returns(FileAttributes.Normal);
+ var fileSystem = CreateFileSystem(path, dirInfo.Object, fileInfo.Object);
+ FileUtility.Instance = fileSystem;
+
+ var controller = CreateVirtualFileSystem();
+ var request = CreateRequest(path);
+ request.Headers.TryAdd("If-Match", "will-not-match");
+
+ // Act
+ var response = await controller.DeleteItem(request);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.PreconditionFailed, response.StatusCode);
+ }
+
+ public static IEnumerable