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
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="DmiSoft.Salesforce.Rest" Version="0.1.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="DmiSoft.Salesforce.Rest" Version="0.1.0" />
                    
Directory.Packages.props
<PackageReference Include="DmiSoft.Salesforce.Rest" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add DmiSoft.Salesforce.Rest --version 0.1.0
                    
#r "nuget: DmiSoft.Salesforce.Rest, 0.1.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package DmiSoft.Salesforce.Rest@0.1.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=DmiSoft.Salesforce.Rest&version=0.1.0
                    
Install as a Cake Addin
#tool nuget:?package=DmiSoft.Salesforce.Rest&version=0.1.0
                    
Install as a Cake Tool

DmiSoft.Salesforce.Rest

Typed .NET client for the Salesforce REST API.

Features

  • Repository pattern over SObjects (ISalesforceRepository<T>) with GetById, 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>) with Find and GetAll.
  • 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 DelegatingHandler pipeline.
  • 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": ""
      }
    }
  }
  ...
}

Version must 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 and Version the 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-sobject folder
  • 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, multipicklist and combobox fields that list at least one active value.
  • Values the org deactivated are left out — Salesforce rejects a write carrying one.
  • {Property}DefaultValue (a string) 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 describe after an admin edits the value set; for values that must be current at the moment of reading, call GetPicklistValuesAsync.
  • 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 an SFC204 message.

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 Done to 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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