DmiSoft.Salesforce.Rest
0.1.0
dotnet add package DmiSoft.Salesforce.Rest --version 0.1.0
NuGet\Install-Package DmiSoft.Salesforce.Rest -Version 0.1.0
<PackageReference Include="DmiSoft.Salesforce.Rest" Version="0.1.0" />
<PackageVersion Include="DmiSoft.Salesforce.Rest" Version="0.1.0" />
<PackageReference Include="DmiSoft.Salesforce.Rest" />
paket add DmiSoft.Salesforce.Rest --version 0.1.0
#r "nuget: DmiSoft.Salesforce.Rest, 0.1.0"
#:package DmiSoft.Salesforce.Rest@0.1.0
#addin nuget:?package=DmiSoft.Salesforce.Rest&version=0.1.0
#tool nuget:?package=DmiSoft.Salesforce.Rest&version=0.1.0
DmiSoft.Salesforce.Rest
Typed .NET client for the Salesforce REST API.
Features
- Repository pattern over SObjects (
ISalesforceRepository<T>) withGetById,Add,Patch,Delete,Upsert(using an external ID). - Reads are two steps: build the query on the repository —
repository.Where(…).Select(…)— then run it with a terminal (ToListAsync,FirstOrDefaultAsync,CountAsync,await foreach). - A separate read-only repository for SOQL views (
ISalesforceViewRepository<TView>) withFindandGetAll. - Fluent SOQL builder with compile-time field name resolution and injection-safe literal escaping.
- Composite unit-of-work session for atomic multi-operation transactions with typed
@{ref.id}placeholders. - Named clients for metadata (
ISalesforceMetadataClient), files (ISalesforceFileClient), Apex REST (ISalesforceApexClient), and bulk delete (ISalesforceBulkClient). - HTTP core with OAuth2 token caching, 401-refresh retry, and 429/5xx back-off via
DelegatingHandlerpipeline. - Three OAuth2 flows — client credentials, JWT bearer and username-password — selected by configuration and validated at startup.
Install
dotnet add package DmiSoft.Salesforce.Rest
The source generator travels inside this package as an analyzer, so there is nothing else to install
and nothing to import: .soql files anywhere in your project and describes under .sf-sobjects/ are
picked up automatically.
Namespaces
The API is split by concern, so most files need one or two of these. There is no root
DmiSoft.Salesforce.Rest namespace to import instead.
| When you are writing | Import |
|---|---|
[SalesforceObject], [SalesforceView] — every entity and view definition file |
DmiSoft.Salesforce.Rest.Query |
ISalesforceRepository<T>, ISalesforceViewRepository<TView>, and the fluent Where / Select / OrderBy / Take / Skip / Include / Query entry points |
DmiSoft.Salesforce.Rest.Repositories |
AddSalesforceClient, AddSalesforceRepository<T>, AddSalesforceViewRepository<TView> — your composition root |
DmiSoft.Salesforce.Rest.DependencyInjection |
SalesforceResult, SalesforceResult<T> — inspecting a write |
DmiSoft.Salesforce.Rest.Results |
ISalesforceCompositeSession and the composite unit of work |
DmiSoft.Salesforce.Rest.Composite |
ISalesforceMetadataClient |
DmiSoft.Salesforce.Rest.Metadata |
ISalesforceFileClient |
DmiSoft.Salesforce.Rest.Files |
ISalesforceApexClient |
DmiSoft.Salesforce.Rest.Apex |
ChildList<T> and the response envelopes |
DmiSoft.Salesforce.Rest.Responses |
ClientConfig, Credentials, GrantType — binding configuration yourself |
DmiSoft.Salesforce.Rest.Config |
ISalesforceHttpClient and the handler pipeline |
DmiSoft.Salesforce.Rest.Http |
ToListAsync, FirstOrDefaultAsync and the other terminals are instance methods on the query the
fluent entry points return, so they need no import of their own.
Every definition file repeats using DmiSoft.Salesforce.Rest.Query;. If you would rather have it
project-wide, add it to your own .csproj — this works whether or not ImplicitUsings is enabled:
<ItemGroup>
<Using Include="DmiSoft.Salesforce.Rest.Query" />
</ItemGroup>
The package deliberately does not inject that for you: a global import you did not write is one that breaks the day a type of yours collides with one of ours, and it makes your files stop compiling when moved to a project without this package.
Quick start
appsettings.json
{
...
"SalesforceClient": {
"Url": "https://myorg.develop.my.salesforce.com",
"Version": "v67.0",
"Credentials": {
"Url": "https://myorg.develop.my.salesforce.com/services/oauth2/token",
"GrantType": "ClientCredentials",
"ClientCredentials": {
"ClientId": "",
"ClientSecret": ""
}
}
}
...
}
Versionmust not be older than the API version your.sf-sobjects/describes were captured at — the describes decide which properties exist, this decides which API version they are queried against. See Keep the describe version andVersionthe same.
SalesforceClient is the default section name, so the argument below is only needed when you moved the
section. Url and Version are the API host and version; everything about signing in lives under
Credentials.
using DmiSoft.Salesforce.Rest.DependencyInjection;
services.AddSalesforceClient(configuration: configuration, sectionName: "SalesforceClient");
Keep secrets out of appsettings.json — use user secrets, environment variables or a vault. The keys
above are the shape, not the place to type them.
User secrets
The same keys, flattened with : — this is how a development machine supplies the credential block
without it reaching the repository:
dotnet user-secrets set "SalesforceClient:Url" "https://myorg.develop.my.salesforce.com"
dotnet user-secrets set "SalesforceClient:Version" "v67.0"
dotnet user-secrets set "SalesforceClient:Credentials:Url" "https://myorg.develop.my.salesforce.com/services/oauth2/token"
dotnet user-secrets set "SalesforceClient:Credentials:GrantType" "ClientCredentials"
dotnet user-secrets set "SalesforceClient:Credentials:ClientCredentials:ClientId" "<consumer key>"
dotnet user-secrets set "SalesforceClient:Credentials:ClientCredentials:ClientSecret" "<consumer secret>"
Switching flows means removing the old sub-block, not just changing GrantType — startup fails while a
sub-block that does not match the grant type is still populated:
dotnet user-secrets remove "SalesforceClient:Credentials:Password:Username"
dotnet user-secrets remove "SalesforceClient:Credentials:Password:Password"
dotnet user-secrets remove "SalesforceClient:Credentials:Password:Token"
dotnet user-secrets remove "SalesforceClient:Credentials:Password:ClientId"
dotnet user-secrets remove "SalesforceClient:Credentials:Password:ClientSecret"
Authentication
SalesforceClient:Credentials holds the token endpoint (Url), the flow (GrantType) and exactly one
credential sub-block. GrantType picks the flow and, with it, which sub-block is required. Supplying a
block that does not match the grant type fails startup, so a password left behind after a migration cannot
linger unnoticed.
GrantType |
sub-block | use it for |
|---|---|---|
ClientCredentials |
ClientCredentials |
server-to-server integrations — the default choice |
JwtBearer |
Jwt |
server-to-server where no secret may sit in config |
Password |
Password |
existing orgs only; Salesforce blocks this flow on new ones |
Client credentials authenticates as the Connected App's execution user, so no username is sent:
"Credentials": {
"Url": "https://myorg.develop.my.salesforce.com/services/oauth2/token",
"GrantType": "ClientCredentials",
"ClientCredentials": { "ClientId": "", "ClientSecret": "" }
}
JWT bearer signs an RS256 assertion with your private key — nothing reusable travels over the wire.
Supply the key with exactly one of PrivateKeyPem or PrivateKeyPath:
"Credentials": {
"Url": "https://myorg.develop.my.salesforce.com/services/oauth2/token",
"GrantType": "JwtBearer",
"Jwt": {
"ClientId": "",
"Username": "integration@example.com",
"Audience": "https://login.salesforce.com",
"PrivateKeyPath": "secrets/salesforce.pem"
}
}
Audience is the login host stamped into the assertion's aud claim — https://login.salesforce.com
for production, https://test.salesforce.com for sandboxes. It is not always the same host as
Credentials:Url. AssertionLifetime defaults to three minutes; Salesforce rejects assertions
expiring more than five minutes out.
Password is the username-password flow. Salesforce is retiring it — it is disabled by default on new
orgs and incompatible with MFA — so prefer one of the flows above for anything new. Token is the
security token, appended to the password; leave it empty when the org trusts the caller's IP range:
"Credentials": {
"Url": "https://myorg.develop.my.salesforce.com/services/oauth2/token",
"GrantType": "Password",
"Password": {
"Username": "",
"Password": "",
"Token": "",
"ClientId": "",
"ClientSecret": ""
}
}
Tokens are cached and refreshed on a 401 regardless of flow, so nothing above changes how you call the repositories.
Get sObject definitions via Salesforce CLI
Fields default to string?. Install Salesforce CLI and drop describe metadata into .sf-sobjects/ to promote fields to real types:
sf org login web -a myOrg
sf sobject describe --sobject Account --json --target-org myOrg > .sf-sobjects/Account.json
.sf-sobjects is the default folder. Point SObjectDefinitionFolder somewhere else to move it:
<PropertyGroup>
<SObjectDefinitionFolder>Salesforce\Describes</SObjectDefinitionFolder>
</PropertyGroup>
Keep the describe version and Version the same
Critical. The describes decide which properties are generated; SalesforceClient:Version decides which
API version those properties are then queried against. They are two halves of one contract, and nothing
enforces it — so a describe captured at a different version than the client queries with produces code
that cannot run.
The sf CLI uses its own current API version, which is usually newer than the Version in your
configuration. A field added since your configured version is then generated as an ordinary property and
rejected at runtime:
[INVALID_FIELD] No such column 'ContactSource' on entity 'Contact'.
That message is misleading: the field genuinely exists in the org, it is simply not exposed at the older
API version being requested. It fails on any read that touches the column — a plain Select, an
unprojected read, or an Include subselect — and only on the SObjects that actually gained a field, so it
can look like one object is broken while the rest are fine.
Check what a describe was captured at — every file records it in its urls node:
Select-String -Path .sf-sobjects\Account.json -Pattern '"describe"\s*:\s*"[^"]*"' |
Select-Object -First 1 -ExpandProperty Matches | Select-Object -ExpandProperty Value
# "describe": "/services/data/v67.0/sobjects/Account/describe"
Use Select-String rather than grep here: redirecting sf output with > in PowerShell writes the file
as UTF-16, which the generator reads happily but grep silently finds nothing in.
Then either raise the client to match:
"SalesforceClient": { "Version": "v67.0" }
or pin the CLI to the version you query with, and re-capture every file in the folder together:
sf sobject describe --sobject Account --json --target-org myOrg --api-version 60.0 > .sf-sobjects/Account.json
Note the two spellings: SalesforceClient:Version takes v60.0, the CLI's --api-version takes 60.0.
A client newer than the describes is safe — Salesforce keeps older fields available in later versions. A client older than the describes is the broken direction. Re-capture the whole folder at once when you upgrade, so the files never disagree with each other either.
Entities and Views
Two shapes, two repositories, split at compile time by a marker interface:
| shape | marker | repository | |
|---|---|---|---|
| Entity | the writable fields of one SObject, flat | ISObject |
ISalesforceRepository<T> — read and write |
| View | a projection shaped by a SOQL query, nested | IView |
ISalesforceViewRepository<TView> — read only |
The source generator puts the marker on the generated half of the partial class, so neither is something
you write. A view has no write verbs to call rather than verbs that throw when called — the constraint on
ISalesforceRepository<T> will not accept it in the first place.
using DmiSoft.Salesforce.Rest.DependencyInjection;
services.AddSalesforceRepository<Account>(); // where T : class, ISObject
services.AddSalesforceViewRepository<AccountDetailView>(); // where TView : class, IView
A hand-written Entity projection — one the generator does not fill in — adds the marker to its own declaration.
Entity - sObject projection class
- request sObject definition and save into
.sf-sobjectfolder - create a partial class
- all public properties will be generated automatically
- extend with custom code
using DmiSoft.Salesforce.Rest.Query;
[SalesforceObject("Account")]
public partial class Account
{
public string GetAccountName(){
return @"Acount name: {this.Name}";
}
}
Picklist values on an entity
A describe already carries every picklist field's local value set, so the generator puts it on the entity as a static — no call to the org to fill a picker:
foreach (var value in Account.RatingValues)
Console.WriteLine($"{value.Label} ({value.Value})");
account.Rating = Account.RatingDefaultValue;
{Property}Values is an IReadOnlyList<PicklistValues.PicklistValue> — the same type
GetPicklistValuesAsync returns, so compile-time and runtime values are interchangeable. ValidFor is
always empty on a generated value: a describe encodes it as a base64 bitmap, not as the UI API's indices.
- Emitted for
picklist,multipicklistandcomboboxfields that list at least one active value. - Values the org deactivated are left out — Salesforce rejects a write carrying one.
{Property}DefaultValue(astring) appears only when the describe flags a default, so a picklist without one fails at compile time rather than handing you a null.- The values are only as fresh as the describe on disk. Re-run
sf sobject describeafter an admin edits the value set; for values that must be current at the moment of reading, callGetPicklistValuesAsync. - If a member of that name already exists — one you wrote, or a field genuinely called
Rating_Values__c— that member keeps the name and the static is skipped with anSFC204message.
View types from SOQL
Drop a .soql file anywhere in your project — the package globs **/*.soql — and declare the partial
class that goes with it:
-- Queries/AccountDetailView.soql
SELECT
Id,
Name,
Owner.Name,
NumberOfEmployees,
(SELECT Id, FirstName, LastName FROM Contacts)
FROM
Account
WHERE
NumberOfEmployees > :minEmployees
using DmiSoft.Salesforce.Rest.Query;
[SalesforceView("Queries/AccountDetailView.soql")]
public partial class AccountDetailView
{
// Hand-written members sit alongside the generated ones.
public string Display => $"{Name} ({Id})";
}
The generator fills in the other half: a property per selected field, a nested type per relationship hop,
and a query builder whose arguments come from the :name binds — each value escaped, never concatenated.
Register repositories
using DmiSoft.Salesforce.Rest.DependencyInjection;
services.AddSalesforceRepository<Account>();
services.AddSalesforceViewRepository<AccountDetailView>();
Reads
A query starts on the repository — it already knows the SObject — and the chain is the query. Building it sends nothing; a terminal runs it:
using DmiSoft.Salesforce.Rest.Repositories;
public class AccountService(
ISalesforceRepository<Account> accounts,
ISalesforceViewRepository<AccountDetailView> accountView)
{
public Task<Account> GetById(string id) =>
accounts.GetById(id);
public Task<IReadOnlyList<Account>> GetByRating(string rating) =>
accounts.Where(x => x.Rating == rating).ToListAsync();
public Task<IReadOnlyList<Account>> GetAll() =>
accounts.Query().ToListAsync();
public Task<IReadOnlyList<AccountDetailView>> GetByNumberOfEmployees(int minNumberOfEmployees) =>
accountView.Find(AccountDetailView.AsQuery(minNumberOfEmployees));
}
Where, OrderBy, OrderByDescending, Take (LIMIT), Skip (OFFSET) and Include can each open
the chain, and go on working after a Select. They take expressions and become part of the SOQL — the
filtering happens in Salesforce, not over the results. None of them is awaited: a query is a description
until a terminal runs it, so every round trip is visible at the call site and takes a CancellationToken.
Projections
Select decides what a row comes back as. Without it a row is the whole entity; with it, a query that
asked for three fields hands back a type with three fields — rather than an Account whose other
properties are null for a reason you cannot see:
var accounts = await repository
.Where(x => x.Rating == "Warm" || x.Rating == "Cold")
.Select(x => new { x.Id, x.Name, x.Rating })
.ToListAsync();
The result type is unconstrained, so an anonymous type works. It cannot cross a method boundary, though — project into a named record when the result has to be returned:
public sealed record AccountSummary(string? Id, string? Name, string? Rating);
public Task<IReadOnlyList<AccountSummary>> GetByRating(string rating) =>
accounts.Where(x => x.Rating == rating)
.Select(x => new AccountSummary(x.Id, x.Name, x.Rating))
.ToListAsync();
Child relationships
An entity carries a member per child relationship its describe lists under childRelationships, and
Include turns one into a SOQL subselect — so the children ride along inside the parent's row instead of
costing a second round trip:
var accounts = await repository
.Include(x => x.Contacts)
.Where(x => x.Rating == "Warm")
.ToListAsync();
// SELECT Id, Name, …, (SELECT Id, FirstName, … FROM Contacts) FROM Account WHERE Rating = 'Warm'
foreach (var contact in accounts[0].Contacts ?? [])
Console.WriteLine(contact.LastName);
The member is a ChildList<T> — a List<T>, so you enumerate, index and LINQ over it directly. Salesforce
actually sends a child collection as an object, { "totalSize": …, "done": …, "records": [ … ] }, and the
type's own converter unpacks that so the shape never reaches your code. A .soql view's subselect binds to
the same type.
It is nullable on purpose: null means the relationship was never included, an empty list means it was
and matched nothing. Those are different facts and the query cannot tell them apart afterwards, so a
forgotten Include reads as a mistake rather than as an account with no contacts.
TotalSize and Done survive the flattening, because they are the only way to detect the truncation
described below:
if (!account.Contacts!.Done)
Console.WriteLine($"only {account.Contacts.Count} of {account.Contacts.TotalSize} loaded");
A member appears only when the child SObject is described too. Account lists around a hundred
SOQL-addressable relationships, but SOQL has no SELECT *: without the child's describe there is no
field list to render the subselect from, so no member is generated either. Downloading the describe is
what makes it appear:
sf sobject describe --sobject AccountBrand --json --target-org myOrg > .sf-sobjects/AccountBrand.json
T is the entity you already declared for that SObject when there is one, so a row can be handed
straight to its own repository. Otherwise the generator synthesizes a flat nested type — ContactsDto
alongside the Contacts member — from the describe.
The child subquery
A subselect can carry its own WHERE, ORDER BY and LIMIT, and a second lambda builds it with the
same verbs as the parent chain — so a child query reads like the query it hangs off:
var accounts = await repository
.Include(x => x.Contacts, c => c
.Select(o => new { o.Id, o.Name })
.Where(o => o.Title == "CEO")
.OrderByDescending(o => o.CreatedDate)
.Take(5))
.OrderBy(x => x.Name)
.ToListAsync();
// SELECT <Account fields>,
// (SELECT Id, Name FROM Contacts WHERE Title = 'CEO' ORDER BY CreatedDate DESC LIMIT 5)
// FROM Account ORDER BY Name
Semantics match the parent verb for verb: two Where calls join with AND, two OrderBy calls append,
and clauses render in SOQL order however the builder was called. Select resolves the child's own wire
mapping, so o => o.MembershipNumber renders Membership_Number__c, and a parent hop inside the child —
o => o.Account!.Name — renders Account.Name.
Without the builder, Include(x => x.Contacts) still means every field and no clauses. Reach for the
builder: an unnarrowed child contributes every scalar field its describe has — around 60 for a real
Contact — and a read travels as a GET with the SOQL in the query string.
There is no Skip. OFFSET inside a subselect is a MALFORMED_QUERY in Salesforce, so it is left off
the surface rather than documented as a trap. It is the one place the child deliberately differs from the
parent chain.
Include widens the query without changing the result type, so a narrowed child is still a
ChildList<Contact>: the fields you did not ask for come back null. That is the trade for the shorter
statement. Project the parent with Select when you want a result type that cannot express them at all.
Including one relationship twice with subqueries that disagree throws, naming both renderings. Salesforce rejects a query that names a relationship twice, so only one can be honoured, and silently keeping the first would hide the other until someone read the SOQL. An identical repeat is harmless.
Include only widens the query. The child members are not part of the default SELECT list, and a
Select projection decides the result type on its own: project into a shape with nowhere to put the
children and they are not read back, however the query was built.
Paging caveat. Only the parent result set is paged. A subselect that returns more rows than Salesforce's per-parent cap carries its own continuation, which is not followed — those children are simply missing from the list. Check
Doneto detect it, and query the child SObject directly through its own repository when a parent may have more children than the cap.
Fields are read by the SOQL path the SELECT list used, not by the projected member's name — so
x.IdfBga finds IDF_BGA__c instead of quietly binding nothing.
Running a query
Every terminal takes a CancellationToken, and the ones that can narrow the query do it in SOQL rather
than over the records that came back:
| Terminal | Result | SOQL sent |
|---|---|---|
.ToListAsync(ct) |
IReadOnlyList<TResult> |
the chain, every page |
.FirstOrDefaultAsync(ct) |
TResult? |
the chain + LIMIT 1 |
.SingleOrDefaultAsync(ct) |
TResult?, throws on a second row |
the chain + LIMIT 2 |
.AnyAsync(ct) |
bool |
the chain + LIMIT 1 |
.CountAsync(ct) |
int |
SELECT COUNT() FROM … with the WHERE |
await foreach (var x in query) |
one page at a time; nothing is requested until enumeration starts | the chain |
CountAsync leaves out the SELECT list and ORDER BY — neither can change a count, and Salesforce
rejects an aggregate query that carries an ORDER BY. Where, Take and Skip still count for it.
var query = repository.Where(x => x.Rating == "Warm"); // nothing sent yet
var howMany = await query.CountAsync(ct); // SELECT COUNT() FROM Account WHERE …
var first = await query.FirstOrDefaultAsync(ct); // … LIMIT 1
var all = await query.ToListAsync(ct); // …
A query built by Soql.From<T>() instead of on a repository has nothing to run it — pass it to
repository.Find(…)/GetAll(…) or to a composite session.
Creates
var case = new Case
{
Subject = "Sample case",
Description = "This is a sample case created via the SalesforceClient.",
Status = "New",
Origin = "Web",
};
var addCaseResult = await caseRepository.Add(case);
Updates
Updates go through one path — Patch — and one body, built field by field:
var patch = PatchBuilder<Contact>.Create()
.Set(x => x.FirstName, "John")
.Set(x => x.AccountId, accountId)
.Clear(x => x.Fax)
.Build();
await contacts.Patch(id, patch, cancellationToken);
{ "FirstName": "John", "AccountId": "001…", "Fax": null }
A field is in the payload because Set or Clear named it, never because of its value. A generated Entity
tracks assignment and can express the same distinction — an untouched Fax stays out of the body while
contactEntity.Fax = null clears it — but PatchBuilder remains the explicit way to say it, and the only way to
patch a record you never loaded.
Field names come from the property's [JsonProperty], so a selector spells the C# name and the API name
follows. Selecting a property the generator marked non-updateable throws, naming the field.
What reaches the wire on a write
Salesforce grants insert and update permission separately, so the generator stamps both onto each property from describe:
[SalesforceWritePolicy(Createable = false, Updateable = true)]
public bool IsPartner { get; set; } // rejected on insert, fine on update
[SalesforceWritePolicy(Createable = false, Updateable = false)]
public DateTimeOffset? CreatedDate { get; init; } // Salesforce assigns it; `init` blocks assignment
Add sends only createable fields, Patch only updateable ones, and Upsert uses the createable set
because it may insert. A field writable in neither direction is init-only, so assigning it after
construction does not compile.
Generated Entities also record which properties you assigned, so a write carries only those:
await repo.Add(new Account { Name = "Acme" }); // body is exactly {"Name":"Acme"}
That matters for non-nullable fields — a bool you never touched is false, not null, and would
otherwise ship its default on every insert. Deserialization resets the record, so loading a record,
changing one field and saving sends just that field. Hand-written Entities do not track assignment and keep
the older rule of sending every non-null property.
Delete
One record
Delete takes the record id and reports the outcome as a SalesforceResult — no exception on a
Salesforce-side refusal, so a delete blocked by a validation rule or a cascade restriction reads like any
other failed write:
var result = await contacts.Delete(id, cancellationToken);
if (!result.IsSuccess)
foreach (var error in result.Errors)
Console.WriteLine($"Delete failed: {error}");
The repository is typed, so the SObject name comes from T and only the id travels in the call.
Many records
ISalesforceBulkClient deletes up to 200 records in one round trip, across any mix of SObject types —
the ids carry their own type prefix, so this client is not bound to a single T:
var bulkClient = services.GetRequiredService<ISalesforceBulkClient>();
var results = await bulkClient.DeleteAsync(
ids: ["003…", "003…", "001…"],
allOrNone: true,
cancellationToken: cancellationToken);
foreach (var item in results.Where(x => !x.Success))
Console.WriteLine($"Could not delete {item.Id}.");
allOrNone decides what a partial failure means. Left at its default of true, one bad id rolls the whole
batch back and nothing is deleted; set it to false and each id stands alone — the successful ones are gone
and the response tells you which were not. The result list comes back in the order the ids went out, one
entry per id either way.
For deletes that are part of a larger unit of work — created, patched and deleted records that must commit
together — use session.Delete<T>(idOrRef) on a composite session instead, covered below.
Composite session
async Task CompositeSessionTest()
{
const string accountId = "001000000000000000";
var compositeClient = services.GetService<ISalesforceCompositeClient>();
if (compositeClient is null)
return;
var session = compositeClient.BeginSession(allOrNone: true);
var newContact = session.Create(new Contact
{
AccountId = accountId,
Salutation = "Mr.",
Title = "Prof. Dr.",
FirstName = "Test Firstname",
LastName = "Test Lastname"
});
if (newContact is null)
return;
session.Patch(newContact.Id, PatchBuilder<Contact>.Create()
.Set(x => x.FirstName, "Updated Firstname")
.Build());
var result = await session.ExecuteAsync(CancellationToken.None);
if (result.IsSuccess)
{
Console.WriteLine($"Created contact {result.GetCreatedId(newContact)}.");
return;
}
foreach (var error in result.GetErrors(newContact))
Console.WriteLine($"Composite error: {error}");
}
Metadata Client
var metadataClient = services.GetRequiredService<ISalesforceMetadataClient>();
Read sObject Picklist Values
var picklistValues = await metadataClient.GetPicklistValuesAsync(objectName: "Case", picklistFieldName: "Origin");
Console.WriteLine($"\nPicklist values for Case/RecordTypeId: {picklistValues.Values.Count} values returned.");
foreach (var value in picklistValues.Values)
Console.WriteLine($" - {value.Label} ({value.Value})");
By default, a master record type will be used. To specify it, use recordType method parameter.
GetPicklistValuesAsync(objectName: "Case", picklistFieldName: "Origin", recordType:"012000000000000AAA")
For the master record type's values, the generator already put them on the entity — see Picklist values on an entity. Use this call when you need values that are current right now, or the ones a specific record type allows.
Read Global Value Set
var globalValueSet = await metadataClient.GetGlobalValueSetAsync(globalValueSetId: "0NtWU0000015x8X");
Console.WriteLine($"\nGlobal value set: {globalValueSet.MasterLabel} ({globalValueSet.DeveloperName}) with {globalValueSet.Metadata.CustomValue.Count} values returned.");
foreach (var value in globalValueSet.Metadata.CustomValue)
Console.WriteLine($" - {value.Label} ({value.ValueName})");
File Client
Internal managing objects based on Salesforce API Version 68
ISalesforceFileClient moves file bytes, which is the one thing the repositories deliberately do not do:
a SOQL row carries a file's metadata, never its content.
var fileClient = services.GetRequiredService<ISalesforceFileClient>();
Salesforce splits a file in two, and so does this client. A ContentDocument id (069…) names the
file itself, across every revision; a ContentVersion id (068…) names one revision and is what
actually holds the bytes. Every method here takes the document id — the version is optional, and
resolving it is the client's job unless you already know which one you want.
Unlike a write, a failed read throws SalesforceApiException (from DmiSoft.Salesforce.Rest.Results)
rather than answering with a result — the HTTP status is on StatusCode, and a document id that matches
nothing (or that this user cannot see) comes back as a 404 rather than as an empty file.
Download
DownloadContentDocumentAsync answers with a DocumentDownload: the bytes, and the ContentVersion they were
read from. The two travel together because the bytes alone do not say what they are — the file name, the
type and the length all live on the version.
// The current version.
var download = await fileClient.DownloadContentDocumentAsync(contentDocumentId, cancellationToken: cancellationToken);
Console.WriteLine($"v{download.Version.VersionNumber} {download.Version.Title} ({download.Content.Length} bytes)");
await File.WriteAllBytesAsync(Path.GetFileName(download.Version.PathOnClient)!, download.Content, cancellationToken);
// A specific version.
var older = await fileClient.DownloadContentDocumentAsync(contentDocumentId, contentVersionId, cancellationToken);
Left unnamed, the version is the one Salesforce flags as IsLatest — not merely the newest row, since a
version can exist without being the published one. Named, it must belong to the document you asked for:
a 068… from somewhere else is a 404 here rather than someone else's file.
Either way this is two round trips — the version lookup and the download — since the version is what
carries both the content path and the metadata. The whole body is buffered into Content, so this is for
files small enough to hold in memory. download.Version is populated exactly as
GetContentDocumentVersionsAsync populates it; see below for which fields that is.
Version history
GetContentDocumentVersionsAsync returns every revision of a document, newest first:
foreach (var version in await fileClient.GetContentDocumentVersionsAsync(contentDocumentId, cancellationToken))
Console.WriteLine($"{version.Id} {version.Title} ({version.PathOnClient}) created {version.CreatedDate:u}");
ContentVersion mirrors the SObject of the same
name
field for field, so Title, PathOnClient (the name the file was uploaded under), FileExtension,
FileType, ContentSize, VersionNumber, IsLatest, IsMajorVersion, PublishStatus,
ReasonForChange and the audit fields all read as they do in Salesforce.
What comes back populated is narrower than what the type carries. GetContentDocumentVersionsAsync selects the
fields every org answers for, and leaves out the ones that depend on a feature or an API version —
Division needs divisions enabled, TextPreview and SharingOption need a recent API version, and
naming a field the org does not have fails the whole query with INVALID_FIELD rather than nulling one
row. Everything unselected stays at its default.
VersionData is left out for a different reason: it is the base64 body, and a SOQL SELECT of it answers
with the download path instead of the content. That path is derivable, so VersionDataUrl is filled in
from the id and is populated whatever the org's API version is — use it for a link or a log. To get the
bytes, pass the version's Id back to DownloadContentDocumentAsync.
Files on a record
GetEntityContentDocumentsAsync goes the other way: given a record, a user, a group or a library, it
returns the ContentDocumentLink rows that share files with it.
foreach (var link in await fileClient.GetEntityContentDocumentsAsync(accountId, cancellationToken))
Console.WriteLine(
$"{link.ContentDocument!.Title}.{link.ContentDocument.FileExtension}" +
$" ({link.ContentDocument.ContentSize} bytes), shared as {link.ShareType}");
A link says where a file is shared and on what terms — ShareType is V viewer, C collaborator or I
inferred from the record, and Visibility is AllUsers, InternalUsers or SharedUsers. The file it
points at comes back on link.ContentDocument, so a listing needs no follow-up query to say what the
files are: Title, FileExtension, FileType, ContentSize and LatestPublishedVersionId are all
there. Only the bytes are not — pass ContentDocumentId to DownloadContentDocumentAsync for those.
That the document is reachable at all is a consequence of the filter. Salesforce will not run a
ContentDocumentLink query that filters on none of Id, ContentDocumentId or LinkedEntityId, so
there is no listing of every share in an org; and of those three, only a filter on LinkedEntityId
permits selecting fields on the linked ContentDocument. Asking by entity is both the shape the object
allows and the one that answers with the most. An entity with no files answers with an empty sequence
rather than a 404.
ContentDocument mirrors the SObject of the same
name
field for field, and what comes back populated is narrower than what the type carries — for the same
reason ContentVersion's is, and along the same line: Division needs divisions enabled,
ContentSizeLong and IsInternalOnly need API v62.0, SharingOption and SharingPrivacy v35.0 and
v41.0, and ParentId needs Salesforce CRM Content. Those stay at their defaults.
Upload a new version
UploadContentDocumentVersionAsync posts a new revision onto an existing ContentDocument. The multipart body is
yours to build, so the part names are the ones the UI API expects: fileData carries the bytes and the
file name, and the optional title names the version as it appears in Salesforce.
using var content = new MultipartFormDataContent();
var fileContent = new ByteArrayContent(bytes);
fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
content.Add(fileContent, "fileData", "report.pdf");
content.Add(new StringContent("Quarterly report"), "title");
var uploaded = await fileClient.UploadContentDocumentVersionAsync(contentDocumentId, content, cancellationToken);
MultipartFormDataContent disposes the parts added to it, so one using covers the whole body.
The upload answers with a bool and nothing else: true means Salesforce accepted the version, false
means it refused and there is no error list to print. It also only ever adds a revision — creating the
ContentDocument in the first place, and linking it to a record through a ContentDocumentLink, are
ordinary writes and belong on a repository or a composite session.
samples/ClientSample runs all three in FileClientTest.
License
MIT
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.AspNetCore.Http.Abstractions (>= 2.3.0)
- Microsoft.Extensions.Caching.Abstractions (>= 10.0.0)
- Microsoft.Extensions.Caching.Memory (>= 10.0.0)
- Microsoft.Extensions.Configuration (>= 10.0.0)
- Microsoft.Extensions.Configuration.UserSecrets (>= 10.0.0)
- Microsoft.Extensions.DependencyInjection (>= 10.0.0)
- Microsoft.Extensions.Http (>= 10.0.0)
- Microsoft.Extensions.Options (>= 10.0.0)
- Newtonsoft.Json (>= 13.0.4)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.1.0 | 117 | 9/15/2026 |
| 0.1.0-beta.5 | 84 | 9/1/2026 |