Skip to content

Commit c1ae10a

Browse files
Merge pull request #72 from phess101/main
Add more composite endpoints. Add token introspection. Add call for custom APEX code
2 parents ae0e923 + ca1833c commit c1ae10a

File tree

11 files changed

+594
-82
lines changed

11 files changed

+594
-82
lines changed

src/NetCoreForce.Client.Tests/UriFormatterTest.cs

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1+
using NetCoreForce.Client.Models;
12
using System;
23
using System.Collections.Generic;
34
using Xunit;
4-
using NetCoreForce.Client;
5-
using NetCoreForce.Client.Models;
65

76
namespace NetCoreForce.Client.Tests
87
{
@@ -65,6 +64,23 @@ public void SObjectsComposite()
6564
Assert.Equal("https://xxx.salesforce.com/services/data/v57.0/composite/sobjects", result);
6665
}
6766

67+
[Fact]
68+
public void CompositeRequest()
69+
{
70+
string result = UriFormatter.CompositeRequest("https://xxx.salesforce.com", "v59.0").AbsoluteUri;
71+
72+
Assert.Equal("https://xxx.salesforce.com/services/data/v59.0/composite", result);
73+
}
74+
75+
[Fact]
76+
public void CompositeSubRequest()
77+
{
78+
string guid = Guid.NewGuid().ToString();
79+
string result = UriFormatter.CompositeSubRequest("v59.0", "Account", guid);
80+
81+
Assert.Equal($"/services/data/v59.0/sobjects/Account/{guid}", result);
82+
}
83+
6884
[Fact]
6985
public void SObjectTree()
7086
{
@@ -159,7 +175,7 @@ public void QueryAll()
159175
"https://xxx.salesforce.com",
160176
"v57.0",
161177
"SELECT Id, Name FROM Account WHERE Id = '001XXXXXXXXXXXXXXX'",
162-
true).AbsoluteUri;
178+
true).AbsoluteUri;
163179

164180
Assert.Equal("https://xxx.salesforce.com/services/data/v57.0/queryAll?q=SELECT%20Id,%20Name%20FROM%20Account%20WHERE%20Id%20%3D%20%27001XXXXXXXXXXXXXXX%27",
165181
result);
@@ -169,7 +185,7 @@ public void QueryAll()
169185
public void Search()
170186
{
171187
//TODO: add actual SOSL syntax
172-
string result = UriFormatter.Search( _instanceUrl, _apiVersion, "X Y").AbsoluteUri;
188+
string result = UriFormatter.Search(_instanceUrl, _apiVersion, "X Y").AbsoluteUri;
173189

174190
Assert.Equal("https://xxx.salesforce.com/services/data/v57.0/search?q=X%20Y", result);
175191
}
@@ -178,13 +194,21 @@ public void Search()
178194
public void Batch()
179195
{
180196
//TODO: add actual SOSL syntax
181-
string result = UriFormatter.Batch( _instanceUrl, _apiVersion).AbsoluteUri;
197+
string result = UriFormatter.Batch(_instanceUrl, _apiVersion).AbsoluteUri;
182198

183199
Assert.Equal("https://xxx.salesforce.com/services/data/v57.0/composite/batch", result);
184200
}
185201

186202
//TODO: Auth URLs
187203

204+
[Fact]
205+
public void OAuthAuthenticationUrl()
206+
{
207+
string result = UriFormatter.OAuthAuthenticationUrl("https://login.salesforce.com/services/oauth2/authorize", "CLIENTID", "CLIENTSECRET", "https://www.theredirectpage.com/callback").AbsoluteUri.ToString();
208+
209+
Assert.Equal("https://login.salesforce.com/services/oauth2/authorize?client_id=CLIENTID&client_secret=CLIENTSECRET&redirect_uri=https%3A%2F%2Fwww.theredirectpage.com%2Fcallback&response_type=code", result);
210+
}
211+
188212
[Fact]
189213
public void WebServerAuthenticationUrl()
190214
{
@@ -203,5 +227,17 @@ public void WebServerAuthenticationUrl()
203227

204228
//TODO: test with url-encoded state parameter
205229
}
230+
231+
[Fact]
232+
public void IntrospectTokenUrl()
233+
{
234+
string result = UriFormatter.IntrospectTokenUrl("https://login.salesforce.com/services/oauth2/introspect", "MYTOKEN", "CLIENTID").AbsoluteUri.ToString();
235+
236+
Assert.Equal("https://login.salesforce.com/services/oauth2/introspect?token=MYTOKEN&client_id=CLIENTID&format=json", result);
237+
238+
result = UriFormatter.IntrospectTokenUrl("https://login.salesforce.com/services/oauth2/introspect", "MYTOKEN", "CLIENTID", "CLIENTSECRET").AbsoluteUri.ToString();
239+
240+
Assert.Equal("https://login.salesforce.com/services/oauth2/introspect?token=MYTOKEN&client_id=CLIENTID&client_secret=CLIENTSECRET&format=json", result);
241+
}
206242
}
207243
}

src/NetCoreForce.Client/AuthenticationClient.cs

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1+
using NetCoreForce.Client.Models;
2+
using Newtonsoft.Json;
13
using System;
24
using System.Collections.Generic;
35
using System.Diagnostics;
46
using System.Net;
57
using System.Net.Http;
6-
using Newtonsoft.Json;
78
using System.Threading.Tasks;
8-
using NetCoreForce.Client.Models;
99

1010
namespace NetCoreForce.Client
1111
{
@@ -21,6 +21,7 @@ public class AuthenticationClient : IDisposable
2121
public AccessTokenResponse AccessInfo { get; private set; }
2222

2323
private const string UserAgent = "netcoreforce-client";
24+
private const string IntrospectTokenEndpointUrl = "https://login.salesforce.com/services/oauth2/introspect";
2425
private const string TokenRequestEndpointUrl = "https://login.salesforce.com/services/oauth2/token";
2526
private readonly HttpClient _httpClient;
2627

@@ -232,6 +233,44 @@ public async Task WebServerAsync(string clientId, string clientSecret, string re
232233
}
233234
}
234235

236+
/// <summary>
237+
/// Introspect a access token
238+
/// </summary>
239+
/// <param name="token">The refresh token the client application already received.</param>
240+
/// <param name="clientId">The Consumer Key from the connected app definition.</param>
241+
/// <param name="clientSecret">The Consumer Secret from the connected app definition. Required unless the Require Secret for Web Server Flow setting is not enabled in the connected app definition.</param>
242+
/// <param name="introspectTokenEndpointUrl"></param>
243+
/// <returns></returns>
244+
public async Task<IntrospectTokenResponse> IntrospectTokenAsync(string token, string clientId, string clientSecret = "", string introspectTokenEndpointUrl = IntrospectTokenEndpointUrl)
245+
{
246+
var uri = UriFormatter.IntrospectTokenUrl(
247+
introspectTokenEndpointUrl,
248+
token,
249+
clientId,
250+
clientSecret);
251+
252+
var request = new HttpRequestMessage
253+
{
254+
Method = HttpMethod.Post,
255+
RequestUri = uri
256+
};
257+
258+
request.Headers.UserAgent.ParseAdd(string.Concat(UserAgent, "/", ApiVersion));
259+
260+
var responseMessage = await _httpClient.SendAsync(request).ConfigureAwait(false);
261+
var response = await responseMessage.Content.ReadAsStringAsync().ConfigureAwait(false);
262+
263+
if (responseMessage.IsSuccessStatusCode)
264+
{
265+
return JsonConvert.DeserializeObject<IntrospectTokenResponse>(response);
266+
}
267+
else
268+
{
269+
var errorResponse = JsonConvert.DeserializeObject<AuthErrorResponse>(response);
270+
throw new ForceAuthException(errorResponse.Error, errorResponse.ErrorDescription, responseMessage.StatusCode);
271+
}
272+
}
273+
235274
/// <summary>
236275
/// Obtain a new access token using a refresh token
237276
/// </summary>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
namespace NetCoreForce.Client.Enumerations
2+
{
3+
public enum CompositeMethod
4+
{
5+
Read,
6+
Write,
7+
Delete
8+
}
9+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
namespace NetCoreForce.Client.Enumerations
2+
{
3+
public enum CompositeType
4+
{
5+
SObject = 0,
6+
SObjectCollection = 1,
7+
}
8+
}

src/NetCoreForce.Client/ForceClient.cs

Lines changed: 110 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
1-
using System;
1+
using NetCoreForce.Client.Enumerations;
2+
using NetCoreForce.Client.Models;
3+
using System;
24
using System.Collections.Generic;
35
using System.Diagnostics;
46
using System.IO;
7+
using System.Linq;
58
using System.Net.Http;
69
using System.Runtime.CompilerServices;
710
using System.Text.RegularExpressions;
811
using System.Threading;
912
using System.Threading.Tasks;
10-
using NetCoreForce.Client.Models;
1113

1214
namespace NetCoreForce.Client
1315
{
@@ -521,7 +523,7 @@ public async Task UpdateRecord<T>(
521523
}
522524

523525
/// <summary>
524-
/// Update multiple reocrds.
526+
/// Update multiple records.
525527
/// The list can contain up to 200 objects.
526528
/// The list can contain objects of different types, including custom objects.
527529
/// Each object must contain an attributes map. The map must contain a value for type.
@@ -636,6 +638,110 @@ public async Task DeleteRecord(string sObjectTypeName, string objectId)
636638
return;
637639
}
638640

641+
/// <summary>
642+
/// Execute multiple composite records.
643+
/// The list can contain up to 200 objects.
644+
/// The list can contain objects of different types, including custom objects.
645+
/// Each object must contain an attributes map. The map must contain a value for type.
646+
/// </summary>
647+
/// <param name="sObjects">Objects to update</param>
648+
/// <param name="allOrNone">Optional. Indicates whether to roll back the entire request when the update of any object fails (true) or to continue with the independent update of other objects in the request. The default is false.</param>
649+
/// <param name="collateSubrequests">Optional. Controls whether the API collates unrelated subrequests to bulkify them (true) or not (false). When subrequests are collated, the processing speed is faster, but the order of execution is not guaranteed (unless there is an explicit dependency between the subrequests).If collation is disabled, then the subrequests are executed in the order in which they are received. The default is true.</param>
650+
/// <param name="customHeaders">Custom headers to include in request (Optional). await The HeaderFormatter helper class can be used to generate the custom header as needed.</param>
651+
/// <returns>List of UpdateMultipleResponse objects, includes response for each object (id, success, errors)</returns>
652+
/// <exception cref="ArgumentException">Thrown when missing required information</exception>
653+
/// <exception cref="ForceApiException">Thrown when update fails</exception>
654+
public async Task<CompositeRequestResponse> ExecuteCompositeRecords(
655+
List<CompositeSObject> sObjects,
656+
bool allOrNone = false,
657+
bool collateSubrequests = true,
658+
Dictionary<string, string> customHeaders = null)
659+
{
660+
if (sObjects == null)
661+
{
662+
throw new ArgumentNullException("sObjects");
663+
}
664+
665+
foreach (CompositeSObject s in sObjects)
666+
{
667+
if (s == null || (string.IsNullOrEmpty(s.Type) && s.CompositeType == CompositeType.SObject))
668+
{
669+
throw new ForceApiException("Objects are missing Type property in Attributes map");
670+
}
671+
}
672+
673+
Dictionary<string, string> headers = new Dictionary<string, string>();
674+
675+
//Add call options
676+
Dictionary<string, string> callOptions = HeaderFormatter.SforceCallOptions(ClientName);
677+
headers.AddRange(callOptions);
678+
679+
//Add custom headers if specified
680+
if (customHeaders != null)
681+
{
682+
headers.AddRange(customHeaders);
683+
}
684+
685+
var uri = UriFormatter.CompositeRequest(InstanceUrl, ApiVersion);
686+
687+
JsonClient client = new JsonClient(AccessToken, _httpClient);
688+
689+
List<CompositeSubRequest> subRequests = sObjects.Select(s =>
690+
{
691+
return new CompositeSubRequest(
692+
s.SObject,
693+
s.Method == CompositeMethod.Write ? string.IsNullOrWhiteSpace(s.Id) ? "POST" : "PATCH" : s.Method == CompositeMethod.Delete ? "DELETE" : "GET",
694+
s.ReferenceId,
695+
s.CompositeType == CompositeType.SObject ? UriFormatter.CompositeSubRequest(ApiVersion, s.Type, s.Id) : UriFormatter.CompositeSObjectCollectionsSubRequest(ApiVersion)
696+
);
697+
}).ToList();
698+
699+
CompositeRequest createMultipleRequest = new CompositeRequest(subRequests, allOrNone, collateSubrequests);
700+
701+
return await client.HttpPostAsync<CompositeRequestResponse>(createMultipleRequest, uri, headers);
702+
703+
}
704+
705+
/// <summary>
706+
/// Execute request against ApexRest custom endpoints.
707+
/// </summary>
708+
/// <param name="apexResourceUrl">The URL of the apex resource. Ex: /services/apexrest/DuplicateCheck should provide "DuplicateCheck"</param>
709+
/// <param name="request">The custom object to include in the request</param>
710+
/// <param name="customHeaders">Custom headers to include in request (Optional). await The HeaderFormatter helper class can be used to generate the custom header as needed.</param>
711+
/// <returns>List of UpdateMultipleResponse objects, includes response for each object (id, success, errors)</returns>
712+
/// <exception cref="ArgumentException">Thrown when missing required information</exception>
713+
/// <exception cref="ForceApiException">Thrown when update fails</exception>
714+
public async Task<T> ExecuteApexPost<Request, T>(string apexResourceUrl, Request request, Dictionary<string, string> customHeaders = null)
715+
{
716+
if (request == null)
717+
{
718+
throw new ArgumentNullException(nameof(request));
719+
}
720+
if (string.IsNullOrWhiteSpace(apexResourceUrl))
721+
{
722+
throw new ArgumentNullException(nameof(apexResourceUrl));
723+
}
724+
725+
Dictionary<string, string> headers = new Dictionary<string, string>();
726+
727+
//Add call options
728+
Dictionary<string, string> callOptions = HeaderFormatter.SforceCallOptions(ClientName);
729+
headers.AddRange(callOptions);
730+
731+
//Add custom headers if specified
732+
if (customHeaders != null)
733+
{
734+
headers.AddRange(customHeaders);
735+
}
736+
737+
var uri = UriFormatter.ApexUri(InstanceUrl, apexResourceUrl);
738+
739+
JsonClient client = new JsonClient(AccessToken, _httpClient);
740+
741+
return await client.HttpPostAsync<T>(request, uri, headers);
742+
743+
}
744+
639745
const string blobUrlRegexString = @"^.+sobjects\/(\w+)\/(\w+)\/(\w+)$";
640746
/// <summary>
641747
/// Retrieve blob data at the specified URL.
@@ -739,7 +845,7 @@ public async Task<OrganizationLimits> GetOrganizationLimits()
739845

740846
/// <summary>
741847
/// List summary information about each REST API version currently available, including the version, label, and a link to each version's root.
742-
/// You do not need authentication to retrieve the list of versions.
848+
/// You do not need authentication to retrieve the list of versions.
743849
/// </summary>
744850
/// <param name="currentInstanceUrl">Current instance URL. If the client has been initialized, the parameter is optional and the client's current instance URL will be used</param>
745851
/// <returns>List of SalesforceVersion objects</returns>
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
using Newtonsoft.Json;
2+
using System.Collections.Generic;
3+
4+
namespace NetCoreForce.Client.Models
5+
{
6+
public class CompositeRequest
7+
{
8+
/// <summary>
9+
/// Constructor
10+
/// </summary>
11+
/// <param name="requests"></param>
12+
/// <param name="allOrNone"></param>
13+
/// <param name="collateSubrequests"></param>
14+
public CompositeRequest(List<CompositeSubRequest> requests, bool allOrNone = false, bool collateSubrequests = true)
15+
{
16+
CompositeRequests = requests;
17+
AllOrNone = allOrNone;
18+
CollateSubrequests = collateSubrequests;
19+
}
20+
21+
/// <summary>
22+
/// Required. A list of Composite Sub Requests
23+
/// </summary>
24+
[JsonProperty(PropertyName = "compositeRequest")]
25+
public List<CompositeSubRequest> CompositeRequests { get; set; }
26+
27+
/// <summary>
28+
/// Optional. Indicates whether to roll back the entire request when the update of any object fails (true) or
29+
/// to continue with the independent update of other objects in the request. The default is false.
30+
/// </summary>
31+
[JsonProperty(PropertyName = "allOrNone")]
32+
public bool AllOrNone { get; set; }
33+
34+
/// <summary>
35+
/// Optional. Controls whether the API collates unrelated subrequests to bulkify them (true) or not (false).
36+
/// In API version 49.0 and later, the default value is true. In version 48.0, the default value is false.
37+
/// </summary>
38+
[JsonProperty(PropertyName = "collateSubrequests")]
39+
public bool CollateSubrequests { get; set; }
40+
}
41+
}

0 commit comments

Comments
 (0)