Skip to content

Commit

Permalink
Implement request command (#36)
Browse files Browse the repository at this point in the history
* Implemented webhook command

* Standardized terminology from 'fetch' to 'request' and did minor cleanup

* Added more robust validation for URLs given to run command; fixed issue where some values weren't being passed to the V8 engine from a request; minor cleanup for PR

* Added tests to cover the contexts we send to the worker functions
  • Loading branch information
doug-jacob authored Feb 5, 2025
1 parent 17e5ebd commit cdd9dbe
Show file tree
Hide file tree
Showing 5 changed files with 233 additions and 19 deletions.
144 changes: 144 additions & 0 deletions Trell.Test/Engine/EngineTest.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
using System.Diagnostics;
using System.Text;
using Google.Protobuf;
using Google.Protobuf.WellKnownTypes;
using Microsoft.ClearScript;
using Trell.Engine;
using Trell.Engine.ClearScriptWrappers;
using Trell.Engine.Extensibility;
using Trell.Engine.Extensibility.Interfaces;
using Trell.Engine.Utility.IO;
using Trell.Rpc;
using static Trell.Engine.ClearScriptWrappers.EngineWrapper;

namespace Trell.Test.Engine;
Expand Down Expand Up @@ -38,6 +41,69 @@ public EngineFixture() {
while (true) { }
"""
);
WriteWorkerJsFile("request_sanity_check.js", onRequest: """
let expected = 'http://fake.url';
if (context.request.url !== expected) {
throw new Error(`Expected: ${expected}, Actual: ${context.request.url}`);
}
expected = 'POST';
if (context.request.method !== expected) {
throw new Error(`Expected: ${expected}, Actual: ${context.request.method}`);
}
expected = 'abcd';
const td = new TextDecoder();
let actual = td.decode(context.request.body);
if (actual !== expected) {
throw new Error(`Expected: ${expected}, Actual: ${actual}`);
}
// JS doesn't do value equality for objects, so this is a workaround
expected = JSON.stringify({ 'Header0': 'Value0', 'Header1': 'Value1' });
actual = JSON.stringify(context.request.headers);
if (actual !== expected) {
throw new Error(`Expected: ${expected}, Actual: ${actual}`);
}
return true;
"""
);
WriteWorkerJsFile("cron_sanity_check.js", onCronTrigger: """
let expected = 'TEST';
if (context.trigger.cron !== expected) {
throw new Error(`Expected: ${expected}, Actual: ${context.trigger.cron}`);
}
expected = new Date('2011-04-14T02:44:16.0000000Z').toString();
let actual = context.trigger.timestamp.toString();
if (actual !== expected) {
throw new Error(`Expected: ${expected}, Actual: ${actual}`);
}
return true;
"""
);
WriteWorkerJsFile("upload_sanity_check.js", onUpload: """
let expected = 'test.txt';
if (context.file.name !== expected) {
throw new Error(`Expected: ${expected}, Actual: ${context.file.name}`);
}
expected = 'TEST TEXT';
let actual = await context.file.text();
if (actual !== expected) {
throw new Error(`Expected: ${expected}, Actual: ${actual}`);
}
expected = 'text/plain';
if (context.file.type !== expected) {
throw new Error(`Expected: ${expected}, Actual: ${context.file.type}`);
}
return true;
"""
);
}

void WriteWorkerJsFile(
Expand Down Expand Up @@ -237,6 +303,84 @@ public async Task TestWorkersCanBeTimedOutAtLoadingStep() {
await Assert.ThrowsAsync<ScriptInterruptedException>(async () => await eng.RunWorkAsync(ctx, work));
}

[Fact]
public async Task TestCronReceivesExpectedContext() {
var eng = MakeNewEngineWrapper();
var ctx = MakeNewExecutionContext();

var parsed = TrellPath.TryParseRelative("cron_sanity_check.js", out var workerPath);
Assert.True(parsed);

Function fn = new() {
OnCronTrigger = new() {
Cron = "TEST",
Timestamp = Timestamp.FromDateTime(DateTime.Parse("2011-04-14T02:44:16.0000000Z").ToUniversalTime()),
}
};

var work = new Work(new(), "{}", this.fixture.EngineDir, "onCronTrigger") {
WorkerJs = workerPath!,
Arg = fn.ToFunctionArg(eng),
};

var result = await eng.RunWorkAsync(ctx, work) as string;
Assert.Equal("true", result);
}

[Fact]
public async Task TestRequestReceivesExpectedContext() {
var eng = MakeNewEngineWrapper();
var ctx = MakeNewExecutionContext();

var parsed = TrellPath.TryParseRelative("request_sanity_check.js", out var workerPath);
Assert.True(parsed);

Function fn = new() {
OnRequest = new() {
Url = "http://fake.url",
Method = "POST",
Headers = {
{ "Header0", "Value0" },
{ "Header1", "Value1" },
},
Body = ByteString.CopyFrom(Encoding.UTF8.GetBytes("abcd")),
}
};

var work = new Work(new(), "{}", this.fixture.EngineDir, "onRequest") {
WorkerJs = workerPath!,
Arg = fn.ToFunctionArg(eng),
};

var result = await eng.RunWorkAsync(ctx, work) as string;
Assert.Equal("true", result);
}

[Fact]
public async Task TestUploadReceivesExpectedContext() {
var eng = MakeNewEngineWrapper();
var ctx = MakeNewExecutionContext();

var parsed = TrellPath.TryParseRelative("upload_sanity_check.js", out var workerPath);
Assert.True(parsed);

Function fn = new() {
OnUpload = new() {
Filename = "test.txt",
Content = ByteString.CopyFrom(Encoding.UTF8.GetBytes("TEST TEXT")),
Type = "text/plain",
}
};

var work = new Work(new(), "{}", this.fixture.EngineDir, "onUpload") {
WorkerJs = workerPath!,
Arg = fn.ToFunctionArg(eng),
};

var result = await eng.RunWorkAsync(ctx, work) as string;
Assert.Equal("true", result);
}

static EngineWrapper MakeNewEngineWrapper(RuntimeLimits? limits = null) {
var rt = new RuntimeWrapper(
new TrellExtensionContainer(new DummyLogger(), null, [], []),
Expand Down
1 change: 1 addition & 0 deletions Trell.Test/Trell.Test.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

<ItemGroup>
<ProjectReference Include="..\Trell.Engine\Trell.Engine.csproj" />
<ProjectReference Include="..\Trell\Trell.csproj" />
</ItemGroup>

</Project>
1 change: 1 addition & 0 deletions Trell/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("Trell.Test")]
102 changes: 85 additions & 17 deletions Trell/CliCommands/RunCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ public class RunCommandSettings : CommandSettings {
[CommandArgument(1, "[data-file]"), Description("File path for data to upload")]
public string? DataFile { get; set; }

[CommandOption("--url <url>"), Description("Sets the request's URL")]
public string? Url { get; set; }

[CommandOption("-H|--header <header-value>"), Description("Adds a header to the request")]
public string[]? Headers { get; set; }

public Dictionary<string, string>? ValidatedHeaders { get; private set; }

public override ValidationResult Validate() {
if (string.IsNullOrEmpty(this.HandlerFn)) {
return ValidationResult.Error("A worker handler must be passed as an argument");
Expand All @@ -29,6 +37,46 @@ public override ValidationResult Validate() {
|| !File.Exists(Path.GetFullPath(this.DataFile))) {
return ValidationResult.Error("Uploading requires a valid path for an existing file be passed as an argument");
}
} else if (this.HandlerFn == "request") {
if (string.IsNullOrWhiteSpace(this.DataFile)
|| !File.Exists(Path.GetFullPath(this.DataFile))) {
return ValidationResult.Error("A valid path to an existing file is required for making requests");
}
if (this.Url is not null) {
if (!this.Url.StartsWith("http://") && !this.Url.StartsWith("https://")) {
return ValidationResult.Error("HTTP requests must start with \"http://\" or \"https://\"");
}
var hostNameIdx = this.Url.IndexOf("://") + "://".Length;
if (hostNameIdx == this.Url.Length) {
return ValidationResult.Error("Missing host name in URL");
}
var hostNameEndIdx = this.Url.IndexOfAny(['/', ':'], hostNameIdx);
if (hostNameEndIdx < 0) {
hostNameEndIdx = this.Url.Length;
}
var hostName = this.Url[hostNameIdx..hostNameEndIdx];
if (Uri.CheckHostName(hostName) == UriHostNameType.Unknown) {
return ValidationResult.Error($"Invalid host name given for URL: {hostName}");
}
if (!Uri.TryCreate(this.Url, UriKind.Absolute, out var validUri)) {
return ValidationResult.Error("An ill-formed URL was given as an argument");
}
this.Url = validUri.AbsoluteUri;
}
if (this.Headers is not null && this.Headers.Length > 0) {
this.ValidatedHeaders = [];
for (int i = 0; i < this.Headers.Length; i++) {
var str = this.Headers[i];
if (string.IsNullOrEmpty(str)) {
return ValidationResult.Error("Headers must be formatted like this: \"Header-Name: Header-Value\"");
}
var split = str.Split(':');
if (split.Length != 2) {
return ValidationResult.Error("Headers must be formatted like this: \"Header-Name: Header-Value\"");
}
this.ValidatedHeaders[split[0].Trim()] = split[1].Trim();
}
}
}
return ValidationResult.Success();
}
Expand Down Expand Up @@ -77,7 +125,7 @@ Rpc.ServerWorkOrder GetServerWorkOrder(RunCommandSettings settings, TrellConfig
},
},
Workload = new() {
Function = GetFunction(settings.HandlerFn ?? "", settings.DataFile),
Function = GetFunction(settings),
Data = new() {
Text = JsonSerializer.Serialize(new { timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString() }),
},
Expand All @@ -93,11 +141,38 @@ Rpc.ServerWorkOrder GetServerWorkOrder(RunCommandSettings settings, TrellConfig
};
}

static Rpc.Upload GenerateUpload(string? uploadDataPath) {
if (string.IsNullOrWhiteSpace(uploadDataPath)) {
static Rpc.Request GenerateRequest(RunCommandSettings settings) {
if (string.IsNullOrWhiteSpace(settings.DataFile)) {
return new();
}
var requestDataPath = Path.GetFullPath(settings.DataFile);
var fileName = Path.GetFileName(requestDataPath);
var requestDataBytes = File.ReadAllBytes(requestDataPath);
if (!new FileExtensionContentTypeProvider().TryGetContentType(fileName, out var fileType)) {
// Fallback to ASP.Net's default MIME type for binary files
fileType = "application/octet-stream";
}

Dictionary<string, string> headers = settings.ValidatedHeaders ?? [];
if (!headers.ContainsKey("Content-Encoding")) {
headers["Content-Encoding"] = fileType;
}

return new() {
Url = settings.Url ?? "http://www.example.com/fetch",
Method = "POST",
Headers = {
headers,
},
Body = ByteString.CopyFrom(requestDataBytes),
};
}

static Rpc.Upload GenerateUpload(RunCommandSettings settings) {
if (string.IsNullOrWhiteSpace(settings.DataFile)) {
return new();
}
uploadDataPath = Path.GetFullPath(uploadDataPath);
var uploadDataPath = Path.GetFullPath(settings.DataFile);
var fileName = Path.GetFileName(uploadDataPath);
var uploadDataBytes = File.ReadAllBytes(uploadDataPath);
if (!new FileExtensionContentTypeProvider().TryGetContentType(fileName, out var fileType)) {
Expand All @@ -111,27 +186,20 @@ static Rpc.Upload GenerateUpload(string? uploadDataPath) {
};
}

Rpc.Function GetFunction(string handler, string? uploadDataPath) {
Rpc.Function GetFunction(RunCommandSettings settings) {
var handler = settings.HandlerFn ?? "";
return handler switch {
"cron" => new() {
OnCronTrigger = new() {
Cron = "* * * * *",
Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
},
},
"request" =>
//OnRequest = new() {
// Url = "http://localhost:9305/events/1/pay",
// Method = "POST",
// Headers = {
// { "Accept", "text/plain" },
// { "Accept-Language", "en/US" },
// },
// Body = ByteString.CopyFromUtf8("Update your payment records."),
//},
throw new NotImplementedException("Running request payloads from the CLI is not implemented yet."),
"request" => new() {
OnRequest = GenerateRequest(settings),
},
"upload" => new() {
OnUpload = GenerateUpload(uploadDataPath),
OnUpload = GenerateUpload(settings),
},
_ => throw new ArgumentOutOfRangeException(handler.ToString()),
};
Expand Down
4 changes: 2 additions & 2 deletions Trell/Rpc/ToEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,9 @@ public static EngineWrapper.Work.ArgType ToFunctionArg(this Function fn, EngineW
engine.CreateScriptObject(new Dictionary<string, object> {
["url"] = fn.OnRequest.Url,
["method"] = fn.OnRequest.Method,
["headers"] = fn.OnRequest.Headers.ToPropertyBag(),
["headers"] = engine.CreateScriptObject(fn.OnRequest.Headers.ToDictionary(x => x.Key, y => (object)y.Value)),
// TODO: This will end up creating an unnecessary allocation and copy.
["body"] = fn.OnRequest.Body.ToByteArray().SyncRoot,
["body"] = engine.CreateJsBuffer(fn.OnRequest.Body.ToByteArray()),
// TODO: What we want is to directly convert the Memory
// TODO: to a Javascript array which requires V8Engine access.
//["body"] = fn.OnRequest.Body.Memory,
Expand Down

0 comments on commit cdd9dbe

Please sign in to comment.