Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 166 additions & 0 deletions src/WebJobs.Script.WebHost/Controllers/FunctionsController.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Controller responsible for administrative and management operations on functions
/// example retriving a list of functions, invoking a function, creating a function, etc
/// </summary>
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}$(?<!^host$)", RegexOptions.Compiled | RegexOptions.IgnoreCase);

public FunctionsController(IWebFunctionsManager functionsManager, WebScriptHostManager scriptHostManager, ILoggerFactory loggerFactory)
{
_functionsManager = functionsManager;
_scriptHostManager = scriptHostManager;
_logger = loggerFactory?.CreateLogger(ScriptConstants.LogCategoryFunctionsController);
}

[HttpGet]
[Route("admin/functions")]
[Authorize(Policy = PolicyNames.AdminAuthLevel)]
public async Task<IActionResult> List()
{
return Ok(await _functionsManager.GetFunctionsMetadata(Request));
}

[HttpGet]
[Route("admin/functions/{name}")]
[Authorize(Policy = PolicyNames.AdminAuthLevel)]
public async Task<IActionResult> 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<IActionResult> 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<string, object> arguments = new Dictionary<string, object>()
{
{ 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<string> 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<IActionResult> 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);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -25,21 +29,21 @@
namespace Microsoft.Azure.WebJobs.Script.WebHost.Controllers
{
/// <summary>
/// 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
/// </summary>
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;
}

Expand Down Expand Up @@ -214,4 +218,4 @@ public async Task<IActionResult> ExtensionWebHookHandler(string name, Cancellati
return NotFound();
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Maps FunctionMetadata to FunctionMetadataResponse.
/// </summary>
/// <param name="functionMetadata">FunctionMetadata to be mapped.</param>
/// <param name="request">Current HttpRequest</param>
/// <param name="config">ScriptHostConfig</param>
/// <returns>Promise of a FunctionMetadataResponse</returns>
public static async Task<FunctionMetadataResponse> 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?
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@fabiocav do you know the answer to this?

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<JObject> 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<string> 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}");
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
21 changes: 21 additions & 0 deletions src/WebJobs.Script.WebHost/Extensions/HttpHeadersExtensions.cs
Original file line number Diff line number Diff line change
@@ -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<string, StringValues> 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()));
}
}
}
Loading