Skip to content

Commit

Permalink
Middle or low level APIs for executing operations. (#125)
Browse files Browse the repository at this point in the history
* OperationRequest.

* OperationRequest.PostData

* Fix property order.

* FIX: Deserialization without exception if the target type is ErrorResponse.

* New operations API (part-1): CallFunctionAsync, ExecuteActionAsync, ProcessOperationResponseAsync.

* Documentation.

* FIX: Use UploadData.FileName well.

* Refactor: use better API names.
  • Loading branch information
kavics authored Jan 3, 2024
1 parent c8c175f commit 2b82af7
Show file tree
Hide file tree
Showing 8 changed files with 450 additions and 11 deletions.
108 changes: 102 additions & 6 deletions src/SenseNet.Client.IntegrationTests/ContentTests.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
using System.Net.WebSockets;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging;
using SenseNet.Extensions.DependencyInjection;
using System.Threading.Channels;
using IdentityModel.Client;
using Newtonsoft.Json;
using SenseNet.Client.Security;

namespace SenseNet.Client.IntegrationTests;

Expand Down Expand Up @@ -600,4 +597,103 @@ await GetRepositoryCollection(
await repository.DeleteContentAsync(contentId, true, cancel).ConfigureAwait(false);
Assert.IsFalse(await repository.IsContentExistsAsync(path, cancel).ConfigureAwait(false));
}

/* ================================================================================================== OPERATIONS */

[TestMethod]
public async Task IT_Op_CallFunction()
{
var repository = await GetRepositoryCollection()
.GetRepositoryAsync("local", CancellationToken.None).ConfigureAwait(false);

// ACT
var request = new OperationRequest() {ContentId = 2, OperationName = "GetPermissions"};
var getPermissionsResponse = await repository.InvokeFunctionAsync<GetPermissionsResponse>(request, CancellationToken.None);

// ASSERT
Assert.AreEqual(2, getPermissionsResponse.Id);
Assert.AreEqual("/Root", getPermissionsResponse.Path);
Assert.AreEqual(true, getPermissionsResponse.Inherits);
Assert.IsNotNull(getPermissionsResponse.Entries);
Assert.IsTrue(getPermissionsResponse.Entries.Length > 2);
Assert.AreEqual("allow", getPermissionsResponse.Entries[0].Permissions.See.Value);
}
[TestMethod]
public async Task IT_Op_ExecuteAction()
{
var repository = await GetRepositoryCollection()
.GetRepositoryAsync("local", CancellationToken.None).ConfigureAwait(false);
var cancel = new CancellationTokenSource().Token;

var content = repository.CreateContent("/Root/Content", "SystemFolder", Guid.NewGuid().ToString());
await content.SaveAsync(cancel);
Assert.AreNotEqual(0, content.Id);
Assert.IsTrue(await repository.IsContentExistsAsync(content.Path, cancel));

// ACT
var postData = new {permanent = true};
var request = new OperationRequest() { ContentId = content.Id, OperationName = "Delete", PostData = postData};
await repository.InvokeActionAsync(request, CancellationToken.None);

// ASSERT
Assert.IsFalse(await repository.IsContentExistsAsync(content.Path, cancel));
}
[TestMethod]
public async Task IT_Op_ProcessOperationResponse()
{
var repository = await GetRepositoryCollection()
.GetRepositoryAsync("local", CancellationToken.None).ConfigureAwait(false);

var request = new OperationRequest
{
ContentId = 2,
OperationName = "GetPermissions"
};

// ACT
string? response = null;
await repository.ProcessOperationResponseAsync(request, HttpMethod.Get,
(r) => { response = r; }, CancellationToken.None);

// ASSERT
var getPermissionsResponse = JsonConvert.DeserializeObject<GetPermissionsResponse>(response);
Assert.AreEqual(2, getPermissionsResponse.Id);
Assert.AreEqual("/Root", getPermissionsResponse.Path);
Assert.AreEqual(true, getPermissionsResponse.Inherits);
Assert.IsNotNull(getPermissionsResponse.Entries);
Assert.IsTrue(getPermissionsResponse.Entries.Length > 2);
Assert.AreEqual("allow", getPermissionsResponse.Entries[0].Permissions.See.Value);
}
[TestMethod]
public async Task IT_Op_ProcessOperationResponse_Error()
{
var repository = await GetRepositoryCollection()
.GetRepositoryAsync("local", CancellationToken.None).ConfigureAwait(false);

var request = new OperationRequest()
{
ContentId = 2,
OperationName = "TestOperation"
};

// ACT
var isResponseProcessorCalled = false;
Exception? exception = null;
try
{
await repository.ProcessOperationResponseAsync(request, HttpMethod.Get,
(r) => { isResponseProcessorCalled = true; }, CancellationToken.None);
Assert.Fail("ClientException was not thrown.");
}
catch (ClientException e)
{
exception = e;
}

// ASSERT
Assert.IsFalse(isResponseProcessorCalled);
Assert.IsTrue(exception.Message.Contains("Operation not found"));
Assert.IsTrue(exception.Message.Contains("TestOperation"));
}

}
88 changes: 88 additions & 0 deletions src/SenseNet.Client.Tests/UnitTests/ODataRequestTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1162,4 +1162,92 @@ public void UploadRequest_ParentIdAndPath_PathIgnored()
}

#endregion

[TestMethod]
public void ActionRequest_Id()
{
var request = new OperationRequest
{
ContentId = 42,
OperationName = "Operation1",
};
Assert.AreEqual($"{_baseUri}/OData.svc/content(42)/Operation1?metadata=no",
request.ToODataRequest(null).ToString());
}
[TestMethod]
public void ActionRequest_IdAndPathAndParameters()
{
var request = new OperationRequest
{
ContentId = 42,
Path = "/Root/Content",
OperationName = "Operation1",
};
request.Parameters.Add("param1", "value1");
request.Parameters.Add("param2", "value2");
Assert.AreEqual($"{_baseUri}/OData.svc/content(42)/Operation1?metadata=no&param1=value1&param2=value2",
request.ToODataRequest(null).ToString());
}
[TestMethod]
public void ActionRequest_Path()
{
var request = new OperationRequest
{
Path = "/Root/Content",
OperationName = "Operation1",
};
Assert.AreEqual($"{_baseUri}/OData.svc/Root('Content')/Operation1?metadata=no",
request.ToODataRequest(null).ToString());
}
[TestMethod]
public void ActionRequest_PostData()
{
var request = new OperationRequest
{
Path = "/Root/Content",
OperationName = "Operation1",
PostData = new {A = "x", B = -13, C = true}
};
var oDataRequest = request.ToODataRequest(null);
Assert.AreSame(request.PostData, oDataRequest.PostData);
}
[TestMethod]
public void ActionRequest_NoContent()
{
Exception? exception = null;
try
{
var request = new OperationRequest { OperationName = "Operation1" };
var _ = request.ToODataRequest(null).ToString();
Assert.Fail("The expected exception was not thrown.");
}
catch (InvalidOperationException ex)
{
exception = ex;
}

Assert.AreEqual(
"Invalid request properties: Path must be provided.",
exception.Message);
}
[TestMethod]
public void ActionRequest_NoOperation()
{
Exception? exception = null;
try
{
var request = new OperationRequest { Path = "/Root/Content" };
var _ = request.ToODataRequest(null).ToString();
Assert.Fail("The expected exception was not thrown.");
}
catch (InvalidOperationException ex)
{
exception = ex;
}

Assert.AreEqual(
"Invalid request properties: OperationName must be provided.",
exception.Message);
}

}
2 changes: 1 addition & 1 deletion src/SenseNet.Client/ClientException.cs
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ public class ErrorMessage
/// <summary>
/// Human readable error message.
/// </summary>
[JsonProperty(PropertyName = "value", Order = 1)]
[JsonProperty(PropertyName = "value", Order = 2)]
public string Value { get; internal set; }
}
/// <summary>
Expand Down
5 changes: 3 additions & 2 deletions src/SenseNet.Client/JsonHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,9 @@ public static string Serialize(object obj)
/// <returns>The deserialized object from the JSON string.</returns>
public static T Deserialize<T>(string json)
{
if (IsErrorResponse(json, out var exception))
throw exception;
if(typeof(T) != typeof(ErrorResponse))
if (IsErrorResponse(json, out var exception))
throw exception;
return JsonConvert.DeserializeObject<T>(json, JsonHelper.JsonSerializerSettings);
}
/// <summary>
Expand Down
54 changes: 54 additions & 0 deletions src/SenseNet.Client/Repository/IRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -484,8 +484,62 @@ Task<string> GetResponseStringAsync(Uri uri, HttpMethod method, string postData,
/// <returns>A Task that represents the asynchronous operation.</returns>
Task DownloadAsync(DownloadRequest request, Func<Stream, StreamProperties, Task> responseProcessor, CancellationToken cancel);

/// <summary>
/// Calls a server function by the provided <paramref name="request"/>
/// and returns the response converted to the desired object.
/// </summary>
/// <typeparam name="T">Can be any existing class or struct.</typeparam>
/// <param name="request">The <see cref="OperationRequest"/> instance.</param>
/// <param name="cancel">The token to monitor for cancellation requests.</param>
/// <returns>A Task that represents the asynchronous operation and wraps the response object.</returns>
/// <exception cref="ClientException">Thrown if the <paramref name="request"/> is invalid
/// or not the requested operation is not an OData function.
/// Also thrown if the server returns an error object.</exception>
Task<T> InvokeFunctionAsync<T>(OperationRequest request, CancellationToken cancel);
/// <summary>
/// Executes a server action by the provided <paramref name="request"/>.
/// </summary>
/// <param name="request">The <see cref="OperationRequest"/> instance.</param>
/// <param name="cancel">The token to monitor for cancellation requests.</param>
/// <returns>A task that represents an asynchronous operation.</returns>
/// <exception cref="ClientException">Thrown if the <paramref name="request"/> is invalid
/// or not the requested operation is not an OData action.
/// Also thrown if the server returns an error object.</exception>
Task InvokeActionAsync(OperationRequest request, CancellationToken cancel);
/// <summary>
/// Executes a server action by the provided <paramref name="request"/>.
/// and returns the response converted to the desired object.
/// </summary>
/// <typeparam name="T">Can be any existing class or struct.</typeparam>
/// <param name="request">The <see cref="OperationRequest"/> instance.</param>
/// <param name="cancel">The token to monitor for cancellation requests.</param>
/// <returns>A Task that represents the asynchronous operation and wraps the response object.</returns>
/// <exception cref="ClientException">Thrown if the <paramref name="request"/> is invalid
/// or not the requested operation is not an OData action.
/// Also thrown if the server returns an error object.</exception>
Task<T> InvokeActionAsync<T>(OperationRequest request, CancellationToken cancel);

/* ============================================================================ LOW LEVEL API */

/// <summary>
/// Executes a server operation by the provided <paramref name="request"/>.
/// </summary>
/// The operation can be an OData action (POST) or function (GET).
/// See type of operations and parameters on the documentation pages: https://docs.sensenet.com
/// The response can be processed with the <paramref name="responseProcessor"/> callback.
/// The callback is called with a string parameter containing the raw response.
/// <remarks>
/// </remarks>
/// <param name="request">The <see cref="OperationRequest"/> instance.</param>
/// <param name="method">The HTTP method. Can be GET or POST.</param>
/// <param name="responseProcessor">Callback for processing the response.</param>
/// <param name="cancel">The token to monitor for cancellation requests.</param>
/// <returns>A task that represents an asynchronous operation.</returns>
/// <exception cref="ClientException">Thrown if the <paramref name="request"/> or <paramref name="method"/>
/// is invalid or not matched. Also thrown if the server returns an error object.</exception>
Task ProcessOperationResponseAsync(OperationRequest request, HttpMethod method,
Action<string> responseProcessor, CancellationToken cancel);

/// <summary>
/// Sends the specified HTTP request and passes the response to the <paramref name="responseProcessor"/> callback.
/// </summary>
Expand Down
31 changes: 31 additions & 0 deletions src/SenseNet.Client/Repository/OperationRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System;

// ReSharper disable once CheckNamespace
namespace SenseNet.Client;

public class OperationRequest : LoadCollectionRequest
{
public int ContentId { get; set; }
public string OperationName { get; set; }
public object PostData { get; set; }

protected override void AddProperties(ODataRequest oDataRequest)
{
// Avoid InvalidOperationException
if (ContentId > 0 && string.IsNullOrEmpty(Path))
Path = "/Root";

if (string.IsNullOrEmpty(OperationName))
throw new InvalidOperationException("Invalid request properties: OperationName must be provided.");

base.AddProperties(oDataRequest);

oDataRequest.ContentId = ContentId;
oDataRequest.ActionName = OperationName;
oDataRequest.PostData = PostData;

// Set back the "false" value because operation can be called on a single content.
oDataRequest.IsCollectionRequest = false;

}
}
Loading

0 comments on commit 2b82af7

Please sign in to comment.