Using File Pointers in Connectors
File pointer support added on version 1.5.0
of Trimble.Xchange.Connector.SDK
File pointers allow you to move files to and from a target system, either by streaming them directly between the source and target, or by staging the file in App Xchange.
Methods to create and resolve file pointers are accessible via the FilePointerHandler
static class.
Warning
File pointers cannot be used on data object records or within data readers.
CreateFilePointerAsync(Stream content, ...)
This method creates a FilePointer
by uploading file content from a stream. It stages the file in App Xchange, which returns a location that can be used to reference the file later.
- Use this when: The file's contents cannot be resolved from a publicly accessible or presigned URL.
- Parameters:
Stream content
: The readable stream containing the file's data.string? filename
: An optional filename. If not provided,"untitled"
is used.string? contentType
: An optional content type (e.g., "application/json"). If not provided,"application/octet-stream"
is used.CancellationToken cancellationToken
: A token to cancel the operation if needed.
- Returns: A
Task<FilePointer>
containing the location and metadata of the newly staged file.
File staging
- Files staged in App Xchange are available through a presigned URL, which remains valid for twelve hours after the file pointer is created. You can download the file by sending a GET request to this URL.
- To retrieve a staged file, use the
Location
property from its associated file pointer.
CreateFilePointerAsync(string location, ...)
This method creates a FilePointer
by referencing a file that already exists at a public URL. It intelligently fetches the file's metadata (like size and type) by first attempting a HEAD
request and falling back to a GET
request if necessary, without downloading the entire file body.
- Use this when: The file is already hosted at an accessible or presigned URL.
- Parameters:
string location
: The absolute URL where the file is located.string? filename
: An optional filename. If not provided, the method will attempt to resolve the filename from the content disposition headers of the response. If no filename can be determined, it defaults to"untitled"
.string? contentType
: An optional fallback content type. If not provided, the method will attempt to resolve the content type from the content disposition headers of the response. If no content type can be determined, it defaults to"application/octet-stream"
.CancellationToken cancellationToken
: A token to cancel the operation if needed.
- Returns: A
Task<FilePointer>
containing the file's location and resolved metadata.
RetrieveAsync(FilePointer filePointer, ...)
This method retrieves the content of a file represented by an existing FilePointer
. It takes a FilePointer
object and downloads the file from its location.
- Use this when: You have a
FilePointer
and need to access the actual file contents for processing, saving, or forwarding. - Parameters:
FilePointer filePointer
: The file pointer object that you want to resolve.CancellationToken cancellationToken
: A token to cancel the operation if needed.
- Returns: A
Task<Stream>
that you can read to get the file's content.
Getting Started
File pointers may be used as both inputs and outputs for connector actions. For instance, you may want to create a download action which produces one or many file pointers:
[Description("Download an attachment from an invoice.")]
[EnableRealTimeActionProcessing]
public class DownloadInvoiceAttachmentsAction : IStandardAction<DownloadInvoiceAttachmentsActionInput, FilePointer>
{
public DownloadInvoiceAttachmentsActionInput ActionInput { get; set; } = new();
public FilePointer ActionOutput { get; set; } = null!;
public StandardActionFailure ActionFailure { get; set; } = new();
public bool CreateRtap => true;
}
public class DownloadInvoiceAttachmentsActionInput
{
[Required]
[JsonPropertyName("id")]
public long Id { get; init; }
}
This action receives the invoice id and produces a file pointer to its corresponding attachment. You can download many files or produce many file pointers in a single action by using arrays for the input and outputs, like so:
public class DownloadInvoiceAttachmentsAction : IStandardAction<DownloadInvoiceAttachmentsActionInput, FilePointer>
{
public DownloadInvoiceAttachmentsActionInput ActionInput { get; set; } = new();
public FilePointer[] ActionOutput { get; set; } = null!;
public StandardActionFailure ActionFailure { get; set; } = new();
public bool CreateRtap => true;
}
public class DownloadInvoiceAttachmentsActionInput
{
[Required]
[JsonPropertyName("ids")]
public long[] Ids { get; init; }
}
Upload actions could receive the file pointer as an input:
[Description("Upload an attachment to an invoice.")]
[EnableRealTimeActionProcessing]
public class UploadInvoiceAttachmentsAction : IStandardAction<UploadInvoiceAttachmentsActionInput, UploadInvoiceAttachmentsActionOutput>
{
public UploadInvoiceAttachmentsActionInput ActionInput { get; set; } = new();
public UploadInvoiceAttachmentsActionOutput ActionOutput { get; set; } = new();
public StandardActionFailure ActionFailure { get; set; } = new();
public bool CreateRtap => true;
}
public class UploadInvoiceAttachmentsActionInput
{
[Required]
[JsonPropertyName("invoiceId")]
public long InvoiceId { get; init; }
[Required]
[JsonPropertyName("filePointer")]
public FilePointer FilePointer { get; init; } = null!;
// There could be many file pointers
// [Required]
// [JsonPropertyName("filePointer")]
// public FilePointer[] FilePointers { get; init; } = null!;
}
public class UploadInvoiceAttachmentsActionOutput
{
[JsonPropertyName("id")]
[Required]
public long Id { get; init; }
[JsonPropertyName("invoiceId")]
[Required]
public long InvoiceId { get; init; }
[JsonPropertyName("filename")]
[Required]
public string Filename { get; init; } = null!;
}
Web-Accessible Files
File pointers for content accessible via a GET or HEAD request (such as public URLs or URLs with built-in authentication like presigned URLs) can be created using the CreateFilePointerAsync
method that accepts a URL.
private async Task<ActionHandlerOutcome> HandleQueuedActionAsync(
ActionInstance actionInstance,
CancellationToken cancellationToken)
{
var input = JsonSerializer.Deserialize<DownloadInvoiceAttachmentsActionInput>(actionInstance.InputJson)!;
try
{
var filePointer = await FilePointerHandler.CreateFilePointerAsync(
_apiClient.CreateFullUrl($"invoice-attachments/public/{input.Id}"),
cancellationToken: cancellationToken);
return ActionHandlerOutcome.Successful(filePointer);
}
catch (HttpRequestException exception)
{
/// Error handling...
}
}
HTTP calls (HEAD and GET) are made to the provided location. If a request fails due to 401 or 403 an UnauthorizedAccessException
is thrown. Any other failed requests result in an HttpRequestException
.
Private Files
Files that require authentication in order to be resolved must use the Stream
overload of CreateFilePointerAsync
, such that the content stream is passed directly to the method. This means that the developer must authenticate manually against the source system in order to request the file, and then stage the file in App Xchange for transfer to target systems.
// ApiClient.cs
public async Task<Stream> GetStream(string relativeUrl, string accessToken, CancellationToken cancellationToken = default)
{
var response = await _httpClient
.GetAsync(
relativeUrl,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken: cancellationToken)
.ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
response.Dispose();
response.EnsureSuccessStatusCode();
}
return await response.Content.ReadAsStreamAsync(cancellationToken: cancellationToken);
}
// ActionHandler.cs
private async Task<ActionHandlerOutcome> HandleQueuedActionAsync(
ActionInstance actionInstance,
CancellationToken cancellationToken)
{
var input = JsonSerializer.Deserialize<DownloadInvoiceAttachmentsActionInput>(actionInstance.InputJson)!;
try
{
// Resolve content stream
var response = await _apiClient.GetStream(
$"invoice-attachments/{input.Id}",
cancellationToken);
// Create file pointer
var filePointer = await FilePointerHandler.CreateFilePointerAsync(response, cancellationToken: cancellationToken);
return ActionHandlerOutcome.Successful(filePointer);
}
catch (HttpRequestException exception)
{
/// Error handling...
}
}
Resolving File Pointers
The content stream of a file pointer may be resolved via RetrieveAsync
.
public async Task<ActionHandlerOutcome> HandleQueuedActionAsync(ActionInstance actionInstance, CancellationToken cancellationToken)
{
var input = JsonSerializer.Deserialize<UploadInvoiceAttachmentsActionInput>(actionInstance.InputJson)!;
try
{
await using var content = await FilePointerHandler.RetrieveAsync(input.FilePointer, cancellationToken);
var request = new UploadInvoiceAttachmentRequest
{
InvoiceId = input.InvoiceId,
Filename = input.FilePointer.Filename,
Content = content
};
// Consider that file uploads must be done via multipart/form-data
var response = await _apiClient.PostForm<InvoiceAttachmentsDataObject>(
"invoice-attachments",
request,
cancellationToken);
if (!response.IsSuccessful || response.Data == null)
{
/// Failed action...
}
// Build sync operations to update the local cache as well as the Xchange cache system (if the data type is cached)
// For more information on SyncOperations and the KeyResolver, check: https://trimble-xchange.github.io/connector-docs/guides/creating-actions/#keyresolver-and-the-sync-cache-operations
var operations = new List<SyncOperation>();
var keyResolver = new DefaultDataObjectKey();
var key = keyResolver.BuildKeyResolver()(response.Data);
operations.Add(SyncOperation.CreateSyncOperation(nameof(UpdateOperation.Upsert), key.UrlPart, key.PropertyNames, response.Data));
var resultList = new List<CacheSyncCollection>
{
new() { DataObjectType = typeof(InvoiceAttachmentsDataObject), CacheChanges = operations.ToArray() }
};
return ActionHandlerOutcome.Successful(response.Data, resultList);
}
catch (HttpRequestException exception)
{
/// Error handling...
}
}