Writing a provider
To store resources somewhere else, such as a file system, object storage or another database, derive a class from
ResourceRepository (in the Resourcefull.Providers namespace) and register it with UseProvider. The clients handle
names, versioning, pruning, URIs and cancellation plumbing; a provider stores and retrieves.
4.0 This page describes 4.0, where providers derive
from the ResourceRepository base class. In 3.1, a provider implements the IResourceRepository interface instead;
see this page as it was for 3.1,
and Upgrading to 4.0 for moving a provider across.
public static class ResourcefullFileSystemOptionsExtensions
{
public static ResourcefullOptionsBuilder UseFileSystem(this ResourcefullOptionsBuilder builder, string root)
{
builder.Services.AddSingleton(new FileSystemOptions(root));
return builder.UseProvider<FileSystemRepository>(ServiceLifetime.Singleton);
}
}
Every built-in provider aims to support every feature, and a new one ships with an issue for each feature it doesn't
support yet; Provider features shows where each stands. A provider of your own can leave features out:
the clients throw NotSupportedException for those, and the contract tests skip them when its test class says so.
ResourceRepository follows semantic versioning: it changes incompatibly only in a major release of Resourcefull,
so a provider package can depend on a version range such as [4.0.0, 5.0.0). A minor release adds members only with
a default implementation, so a provider written for an earlier release keeps compiling.
The contract
The XML documentation on each member states its contract in full. In short, a provider must:
- Treat names and version IDs as untrusted. The clients only guarantee that names follow the
name rules;
/,\,..and any other characters can still arrive, and version IDs on reads and deletes aren't checked at all. Encode or escape them before using them as file paths or keys. - Compare names and version IDs ordinally (case-sensitive), and size storage for
ResourceNames.MaxContainerNameLength,MaxResourceNameLengthandMaxVersionIdLength. These limits change only in a major release. - Keep exactly one current version per name. The current version is the one stored most recently, not the one
with the latest
LastModified, and concurrent writes and deletes must never leave two. - List versions in store order: the current version first, then from the most recently stored to the oldest.
Pruning keeps the first
MaxVersionsand deletes the rest, so another order deletes the wrong versions. - Decide conflicts and conditions in the write itself: a missing container throws
ResourceContainerNotFoundException, and a create-only write to an existing name or a duplicate container throwsResourceConflictException. A check before the write leaves a window for a concurrent caller. The same goes for the conditions an upload or delete is given (ResourceWriteCondition.ConditionsandResourceConditions): decide them at the moment the version would become current, or under whatever serializes writes to the name. - Treat a name with no current version as meeting no condition, and empty conditions as none. A write that fails
its conditions throws
ResourcePreconditionFailedExceptionwith the current version's ID and content hash, and changes nothing.conditions.ThrowIfNotMet(resource, currentVersionId, currentContentHash)4.0 does exactly this check, so call it with the current version the write reads;IsEmptytells you when there's nothing to check. It's an extension method inResourcefull.Providers, as are the other helpers for providers. - Measure what it stores. Read the content through a
HashingStream, store the length it measured, and returnHashingStream.Complete(declaredLength, lastModified), which also rejects a wrong declared length. Call it as the write makes the version current but before it commits, so a wrong length stores nothing. - Read the content to its end before the version becomes current, until a read returns nothing, and store nothing if that read throws. That last read waits for an upload inspector 4.0 to answer, and throws if it refuses the content, so the version mustn't be current yet. An inspected upload's stream can't seek, so a provider can't retry it by reading the content again.
- Stamp the time a version becomes current. Pass
Completethe time the write makes the version current, not the time before its content was read, and store it as the version'sLastModified. Take an optionalTimeProviderin the constructor, so an application or a test can set the clock. - Store the content hash from
WrittenContentwith the version, and return it asResourceInfo.ContentHashon every read. - Store
ExpiresAtandCopiedFrom4.0 from theNewResourceVersionthatAddResourceAsyncandCopyResourceAsyncare given, and return them on every read. A version only has anExpiresAtif your provider'sSupportsExpiryistrue; see Expiry. - Return content as a read-only, forward-only stream:
CanSeekandCanWritearefalse,Lengthis the content's full length,Positioncounts the bytes read, and seeking, settingPosition, writing andSetLengththrowNotSupportedException. - Store container metadata, and return copies, so callers can't change what's stored. Leave
Uriunset on what you return: the clients fill it in from the URI provider. - Honor cancellation: a canceled operation throws
OperationCanceledExceptionand leaves nothing half-done. The one exception is deleting a container, which may stop between whole versions, as long as each remaining name keeps a current version and calling it again finishes the job. - Be safe to call concurrently for the lifetime it registers with.
A skeleton to start from
Ten members are abstract, so these are what a new provider writes first. Each comment says what the member must do; the contract above and each member's XML documentation say the rest.
public sealed class FileSystemRepository(FileSystemOptions options, TimeProvider? timeProvider = null)
: ResourceRepository
{
private readonly TimeProvider _clock = timeProvider ?? TimeProvider.System;
// Containers
public override Task AddContainerAsync(string containerName,
IReadOnlyDictionary<string, string> metadata, CancellationToken cancellationToken = default)
// Store the name and a copy of the metadata. A container that already exists throws
// ResourceConflictException, decided by the write itself, not by a check before it.
=> throw new NotImplementedException();
public override Task<IReadOnlyList<ContainerInfo>> GetContainerPageAsync(string? prefix, string? afterName,
int pageSize, CancellationToken cancellationToken = default)
// Up to pageSize containers whose names start with prefix and come after afterName, ordered by name, each
// with its own copy of the metadata. Seek on the name rather than reading every container.
=> throw new NotImplementedException();
public override Task<ContainerInfo?> GetContainerInfoAsync(string containerName,
CancellationToken cancellationToken = default)
// The container, or null if there's none by that name (compared ordinally).
=> throw new NotImplementedException();
public override Task<bool> DeleteContainerAsync(string containerName,
CancellationToken cancellationToken = default)
// The container and every version in it; false if it didn't exist. It may stop between whole versions when
// canceled, as long as every name left keeps its current version and calling it again finishes.
=> throw new NotImplementedException();
// Writing
public override Task<WrittenContent> AddResourceAsync(NewResourceVersion version, Stream content,
ResourceWriteCondition? condition = null, CancellationToken cancellationToken = default)
// Read the content through a HashingStream, store it as a new version, and make that version the name's
// current one. With condition.CreateOnly, a name that already has a version throws ResourceConflictException;
// condition.Conditions the current version doesn't meet throw ResourcePreconditionFailedException; a missing
// container throws ResourceContainerNotFoundException. Return HashingStream.Complete(declared length, the
// time from _clock as the version becomes current), and store its length and hash with the version.
=> throw new NotImplementedException();
public override Task<bool> DeleteResourceAsync(ResourceAddress resource, string? versionId = null,
ResourceConditions? conditions = null, CancellationToken cancellationToken = default)
// One version, or every version of the name when versionId is null; false if there was nothing to delete.
// Deleting the current version makes the next most recently stored one current. Conditions the current
// version doesn't meet throw ResourcePreconditionFailedException.
=> throw new NotImplementedException();
// Reading
public override Task<IReadOnlyList<ResourceInfo>> GetResourcePageAsync(string containerName, string? prefix,
string? afterName, int pageSize, CancellationToken cancellationToken = default)
// Up to pageSize current versions whose names start with prefix and come after afterName, ordered by name:
// see Listing by page. Answer it without reading the whole container.
=> throw new NotImplementedException();
public override Task<IReadOnlyList<ResourceInfo>> GetResourceVersionsAsync(ResourceAddress resource,
CancellationToken cancellationToken = default)
// Every version of the name, the current one first, then from the most recently stored to the oldest.
=> throw new NotImplementedException();
public override Task<ResourceInfo?> GetResourceInfoAsync(ResourceAddress resource, string? versionId = null,
CancellationToken cancellationToken = default)
// One version, or the current one when versionId is null; null if there's no such version. The version ID
// comes from the caller unchecked: never let it, or the name, reach outside the name's own storage.
=> throw new NotImplementedException();
public override Task<Stream?> GetResourceStreamAsync(ResourceAddress resource, string? versionId = null,
CancellationToken cancellationToken = default)
// The version's content as a read-only, forward-only stream whose Length is the full length; null if there's
// no such version.
=> throw new NotImplementedException();
}
The rest are virtual, with defaults. Reading a range, copying and container usage work through the members above,
more slowly than a provider that answers them itself. Moving, importing, changing a container's metadata,
changing a version's metadata or content type in place, and GetExpiredAsync, DeleteExpiredVersionAsync and
SetExpiryAsync throw NotSupportedException until the provider overrides them, and the features that need them
report that they aren't supported. The sections below say what each default does, and what to do to
override it.
Listing by page
GetResourcePageAsync is how every listing reads a container, so it has no default: return at most pageSize current
versions whose names start with prefix, ordinally, and come after afterName, ordered by name. Use ordinal order,
or your storage's binary order if its ordering and its "after" comparison agree, so no name is skipped or repeated
between pages.
Reading ranges
The default of the GetResourceStreamAsync overload that takes an offset and a length opens the whole content and
reads past the bytes before offset, so it returns correct ranges, just without the savings. To answer it directly,
return a stream that starts at offset:
Lengthis still the content's full length, so the client can report it, andPositionstarts atoffset, or at the content's length whenoffsetis past it.- Reading may go on past the range. The client reads only
lengthbytes, so a provider can stop reading storage there or carry on to the end, whichever is simpler.
Copying and moving
AddResourceAsync should store NewResourceVersion.CopiedFrom with the version and return it as
ResourceInfo.CopiedFrom on every read, as it does the content hash.
CopyResourceAsync's default reads the source and stores it throughAddResourceAsync, so it copies correctly, just through the application. Override it to copy within your store, deciding the destination's condition as an upload does.RestoreVersionAsynccalls it too, to copy an older version to its own name.MoveResourceAsync's default throwsNotSupportedException. A move must change every version's container and name at once, never leaving the resource in both places or half in each, so made of copies and deletes it could stop halfway. Implement it to keep each version's ID and attributes, refuse a destination name that has versions (ResourceConflictException) or whose container is missing (ResourceContainerNotFoundException), and decide the source conditions in the move itself.
Importing versions
ImportVersionAsync is for importing a container. It stores a version as it was in another
store: with the VersionId and LastModified it's given, rather than new ones, and with its type, metadata,
CopiedFrom and ExpiresAt. Otherwise it's an upload: measure and hash the content as AddResourceAsync does,
refuse a wrong DeclaredLength, and make the version the name's current one. Its default throws
NotSupportedException, so imports fail; exports work with any provider, because they only read.
SetContainerMetadataAsync replaces a container's metadata: an import uses it to mark the container it's
filling as unfinished, and to clear the mark at the end. Its default throws NotSupportedException, and imports then
go unmarked.
GetVersionPageAsync is for exports, which read a container through it 1,000 names at a time. It returns every version
of up to maxNames names after afterName: the names in GetResourcePageAsync's order, and each name's versions in
GetResourceVersionsAsync's, the current version first. Its default lists the names with GetResourcePageAsync and
then asks for each name's versions, a query apiece, so exports work with any provider, just more slowly than with one
that answers it in a single query.
Expiry
SupportsExpiry 4.0 is false by default, for a provider that leaves
expiry out. While it's false, every write that sets an expiry throws NotSupportedException, so
nothing is stored with an expiry that never happens, and UseExpiry fails at startup with
InvalidOperationException. GetExpiredAsync, DeleteExpiredVersionAsync and SetExpiryAsync default to throwing
NotSupportedException.
To support expiry, override SupportsExpiry to return true, store NewResourceVersion.ExpiresAt with the version in
AddResourceAsync and CopyResourceAsync, and return it as ResourceInfo.ExpiresAt on every read. Then:
- List expired versions from an index, a batch at a time. Expiry calls
GetExpiredAsyncevery interval on every instance, so it should cost little when nothing has expired. Order them byExpiresAt, then ordinally by version ID, then by container and name, and start after theafterkey you're given: a sweep pages through every expired version this way, and fails if a provider lists the same batch twice.ResourceVersionKeyholds all four because version IDs are unique only within a name: a copied container's versions keep their IDs and expiries. - Decide expiry in the delete.
DeleteExpiredVersionAsyncdeletes a version only if it's still expired when the delete runs, under whatever serializes writes to the name:SetExpiryAsyncmay have extended it since it was listed. - Change nothing but the expiry in
SetExpiryAsync. - Delete a batch together, if you can. A sweep deletes each page of up to 100 expired versions it lists through
DeleteExpiredVersionsAsync, which returns those it deleted. Decide expiry for each in the delete, as above, and make the next version current for a name whose current one goes. Its default callsDeleteExpiredVersionAsyncfor each version; and if a batch fails, the sweep tries each of its versions that way, so one version that keeps failing doesn't hold up the rest. - Delete what stopped uploads left, if your provider stores content before the version is visible, as SQL
Server's does. The sweep calls
DeleteAbandonedUploadsAsync(staleBefore)first, every time, so make it cheap when there's nothing to delete. Delete only what an upload last added to beforestaleBefore, and return how many uploads' leftovers you deleted. Its default does nothing, for a provider that leaves nothing behind. Only the sweep calls it, so a provider that leaves anything behind should support expiry, or its leftovers stay.
Upload sessions
Upload sessions store a version's content over several appends, which may come from different processes, and make it current when the session completes. A provider supports them by overriding six members, which work on a staged version: one that only these members can see, just as an upload's version stays hidden while its content arrives.
StartStagedVersionAsyncstages a version with no content, keeping its content type, metadata, expiry and declared length. A missing container throwsResourceContainerNotFoundException.GetStagedVersionAsyncreturns the version as it was staged and how many bytes are stored, ornull.AppendStagedContentAsyncappends content, provided the stored content ends at the offset it's given; otherwise it throwsResourceUploadOffsetExceptionwith the stored length, and stores nothing. A provider that can hold only so much in one session throwsResourceUploadLimitExceptiononce the session is full. It may store the content as it arrives, so an append that fails partway keeps what it stored. Make the check and the store one step, as SQL Server's provider does by moving the stored length forward only from the length it expects, in the transaction that stores each batch, so two appends from the same offset can't both succeed. When your provider retries a batch after a transient failure, and the earlier attempt's commit succeeded but its acknowledgment was lost, the retry must recognize its own stored batch rather than refuse it.GetStagedStreamAsyncreads the staged content from the start. The clients read it once, to measure, hash and inspect it, before they complete the session.CompleteStagedVersionAsyncmakes the version current with the length and hash it's given, deciding the write condition asAddResourceAsyncdoes. Refuse a length that isn't what's stored withResourceUploadOffsetException: an append landed after the content was measured. Lock against appends while you check, so none lands between the check and the change.DeleteStagedVersionAsyncdeletes a staged version and its content.
Each append should move the version's stamp forward, so DeleteAbandonedUploadsAsync deletes a session only once
nothing has been appended to it for a day. Only the expiry sweep calls DeleteAbandonedUploadsAsync, so with
a provider that doesn't support expiry, an abandoned session stays until the application cancels it. For a session that
doesn't exist, DeleteStagedVersionAsync returns false, and the others, except StartStagedVersionAsync, return
null. The defaults throw NotSupportedException, so with a provider that doesn't override them, the clients' session methods throw it too.
Changing metadata and content type
SetResourceMetadataAsync and SetContentTypeAsync are for
changing a version in place. Each changes one version, or the
current version when the version ID is null, and returns false when there's no such version. Change nothing else
about the version: its ID, content, hash and LastModified stay the same, and no new version is made. Store a copy of
the metadata you're given, so the caller can't change it afterward, and serialize the change with other writes to the
name, as for SetExpiryAsync. Their defaults throw NotSupportedException, so the clients' methods report that the
provider doesn't support them.
The container client's SetMetadataAsync calls the provider's SetContainerMetadataAsync, described under
Importing versions, so with a provider that doesn't override it, SetMetadataAsync throws
NotSupportedException.
Retention and legal holds
Retention dates and legal holds keep versions from being deleted. SupportsRetention is
false by default, and the clients then refuse every write that would protect a version, with
NotSupportedException, so nothing is stored unprotected. To support them, return true and:
- Store and return both. Keep
NewResourceVersion.RetainUntilandLegalHoldwith each version thatAddResourceAsync,ImportVersionAsync,CopyResourceAsyncand the upload session members store, and return them asResourceInfo.RetainUntilandLegalHoldon every read. A move keeps both with each version. - Override
SetRetentionAsyncandSetLegalHoldAsync. Each changes one version, or the current one for anullversion ID, and returnsfalsewhen there's no such version. Refuse a date before the version's own withArgumentOutOfRangeException, deciding that in the change, under whatever serializes writes to the name. - Refuse protected deletes in the delete itself.
DeleteResourceAsyncthrowsResourceRetainedExceptionwhen any version it would delete can't be deleted now (ResourceInfo.IsDeletableAtisfalse), and deletes nothing;ResourceRetainedException.Create, fromResourcefull.Providers, builds it with the clients' message. Decide it as you decide conditions, in the same step as the delete, so a hold set at the same moment either lands first and stops the delete or finds the version gone.DeleteContainerAsyncdoes the same for any version in the container. A container deleted in batches must also refuse a version protected after the delete started, before it deletes that version. - Leave protected versions to expiry.
GetExpiredAsynclists only versions that may be deleted, andDeleteExpiredVersionAsyncandDeleteExpiredVersionsAsyncleave a protected version as they leave one whose expiry was extended.
The bin
The bin keeps deleted versions for a while. SupportsSoftDelete is false by
default, and the clients then refuse every delete in a container whose policy keeps deleted versions, with
NotSupportedException, so nothing meant to be kept is deleted for good. To support it, support
expiry, whose sweep purges the bin, return true, and:
- Override
SoftDeleteResourceAsync. It deletes asDeleteResourceAsyncdoes, deciding conditions and retention the same way, but keeps the versions, each withDeletedAtset to now andPurgeAtto the time it's given, and leaves their content in place. - Leave the bin out of everything else. A version in the bin isn't returned by any read, listing, version list,
search or
GetExpiredAsync, isn't counted inGetContainerUsageAsyncbut forDeletedSizeandDeletedVersionCount, and isn't moved byMoveResourceAsync. A name whose versions are all in the bin is free for a create-only write, but a version ID in the bin stays taken.DeleteContainerAsyncdeletes the bin too. - List it.
GetDeletedPageAsynclists a container's bin newest deletion first, then by version ID and name, and returns where the next page starts; with a prefix, it may stop early and return a short page rather than read the whole bin.GetDeletedVersionsAsynclists one name's. - Bring versions back.
UndeleteAsyncputs them back among the name's older versions in the order they were first stored, so the current version stays current; if the name has none, the most recently stored becomes current. - Purge it.
PurgeDeletedAsyncdeletes versions in the bin for good, and with a time, only those due by then, deciding that in the purge itself.GetPurgeableAsynclists what's due across every container for the sweep, so answer it from an index, asGetExpiredAsyncis, and order it the same way, withPurgeAtin place ofExpiresAt.
Metadata search
Searching by metadata works with every provider:
FindResourcePageAsync's default reads GetResourcePageAsync's pages and keeps the matches. That reads every name, so
a provider whose store can keep an index should override it:
- Index the searchable keys.
SearchableMetadataKeysholds the keys the application named withUseMetadataSearch. Your provider gets them through the base class's constructor: read them from the store'sResourcefullOptionsin the factory that makes the provider, as below, wherestoreNameisbuilder.StoreName(see Several stores). Keep an index of each current version's values for those keys, and update it in the same step as any write that changes which version is current or changes the current version's metadata: uploads, copies, restores, imports, completed upload sessions, moves, deletes, expiry andSetResourceMetadataAsync. Keys are at most 128 characters. The clients refuse a value over 256 characters for these keys, in writes and searches alike, but a version stored before its key was named can still have one: leave it out of the index. - Override
FindResourcePageAsyncto list the current versions with the given key and value, asGetResourcePageAsynclists them: in name order, with the prefix, after the name given, up to the page size. - Override
IndexMetadataAsyncto index a container's current versions, for keys added after they were stored. Its default does nothing, which suits a provider without an index.
public sealed class FileRepository(FileStoreSettings settings, IReadOnlySet<string> searchableMetadataKeys)
: ResourceRepository(searchableMetadataKeys)
{
// ...
}
builder.UseProvider(services => new FileRepository(services.GetRequiredKeyedService<FileStoreSettings>(storeName),
services.GetRequiredService<IOptionsMonitor<ResourcefullOptions>>().Get(storeName ?? Options.DefaultName)
.SearchableMetadataKeys));
A provider that wraps another, such as one that caches or encrypts, passes FindResourcePageAsync and
IndexMetadataAsync on, so the wrapped provider's index answers searches.
Container usage
GetContainerUsageAsync's default lists every resource's versions, one name at a time, and adds up their
ContentLength, so it reports usage correctly, just slowly for large containers.
Container policies call it before each upload they limit by size or count, so if your
store can add up a container's versions in one query, override it. Count every version in StoredSize and
VersionCount, and each name once in ResourceCount, leaving out the bin, which goes in DeletedSize and
DeletedVersionCount.
GetTopVersionsAsync 4.0 lists a container's
largest versions, or those that expire soonest, for the dashboard and an
administrator's own tools. Its default reads every version through GetVersionPageAsync and keeps the top ones, so it
works with any provider. If your store can sort a container's versions by length or expiry, override it with one
query. For LargestFirst, order by ContentLength descending, with versions that have none last. For
ExpiringFirst, leave out versions without an ExpiresAt and order by it ascending. In both, break ties by version
ID, ordinally.
A database to provision
If your provider keeps a database whose tables it creates and upgrades, derive a class from ResourceDatabase, in
Resourcefull.Providers, and register it for the store. MigrateResourcefullDatabaseAsync and
GenerateResourcefullScript then work for your provider as they do for SQL Server:
builder.Services.AddKeyedSingleton<ResourceDatabase>(builder.StoreName, (services, _) => new FilesDatabase(root));
MigrateAsync brings the database up to date and does nothing when it already is. GenerateScript returns a script
that does the same, for a database administrator to run, and writes it without connecting to the database. A provider
with nothing to provision registers none; for its stores, MigrateResourcefullDatabaseAsync does nothing and
GenerateResourcefullScript throws NotSupportedException.
Several stores
An application can add several stores, each configured with its own options builder. A
provider that needs services of its own, such as its settings or a connection, registers them under the store's name,
builder.StoreName. It then makes the provider with the UseProvider overload that takes a factory, and resolves
those services by the same name:
public static ResourcefullOptionsBuilder UseFiles(this ResourcefullOptionsBuilder builder, string root)
{
string? storeName = builder.StoreName;
builder.Services.AddKeyedSingleton(storeName, (_, _) => new FileStoreSettings(root));
return builder.UseProvider(services =>
new FileRepository(services.GetRequiredKeyedService<FileStoreSettings>(storeName)));
}
The store AddResourcefull adds has no name, and in .NET's own container a null key is the same as no key, so the
same code works for named and unnamed stores. A provider that indexes metadata for search also passes the store's
search keys, as in Metadata search.
Testing a provider
The Resourcefull.Testing package holds the contract tests: about 200 tests of what every provider must do, from
round-tripping content and keeping versions to conditional writes, listings, policies and names. Every built-in
provider passes them, and they're versioned with the core, so a new release's tests check what that release expects.
They're written for xUnit v3, and need xunit.v3 4.0.2 or later. Add the package to an xUnit v3 test project:
dotnet add package Resourcefull.Testing
Then derive a class from ResourcefullContractTests that registers your provider. The suite calls
BuildServiceProvider once per test, and each test works in containers of its own, so all the tests can share one
store:
using Microsoft.Extensions.DependencyInjection;
using Resourcefull.Testing;
public class FileSystemContractTests : ResourcefullContractTests
{
protected override ServiceProvider BuildServiceProvider(Action<ResourcefullOptionsBuilder>? configure = null)
{
var services = new ServiceCollection();
services.AddResourcefull(options =>
{
options.UseFileSystem(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")));
configure?.Invoke(options);
});
return services.BuildServiceProvider();
}
protected override bool SharesStoreBetweenServiceProviders => false;
}
The tests assume that the service providers BuildServiceProvider builds share one store, as two instances of an
application would. This example gives each one a new folder, so it overrides SharesStoreBetweenServiceProviders to
return false, and the few tests that store with one service provider and read with another are skipped.
Run dotnet test, and xUnit runs every test the class inherits. A store that needs a server, such as a database,
can skip its tests when none is available: call Assert.SkipUnless in BuildServiceProvider.
Features your provider leaves out
When your provider leaves out an optional feature, the suite skips that feature's tests. In their place, a test checks
that the clients refuse the feature with NotSupportedException, changing nothing, rather than seem to succeed.
For expiry, retention and the bin, the suite reads your provider's own
SupportsExpiry, SupportsRetention and SupportsSoftDelete, so there's nothing to set in your test class. It gets
them from the unnamed store's ResourceRepository, so register your provider there, as above. A test also checks that
a provider with a bin supports expiry.
The other features have no such flag, so turn off a feature's switch in your test class when your provider leaves it out:
| Switch | Turn it off when the provider doesn't override |
|---|---|
SupportsContainerMetadata |
SetContainerMetadataAsync |
SupportsVersionChanges |
SetResourceMetadataAsync and SetContentTypeAsync |
SupportsMove |
MoveResourceAsync |
SupportsImport |
ImportVersionAsync |
SupportsUploadSessions |
the six upload session members |
public class FileSystemContractTests : ResourcefullContractTests
{
protected override bool SupportsMove => false;
// BuildServiceProvider as above.
}
A switch added after 4.0 is off by default, so that a new release's tests don't fail a provider written before the feature existed. Turn it on once your provider supports the feature.
The members with a default that works, such as CopyResourceAsync and GetContainerUsageAsync, need no switch: the
suite tests them either way.
How the store keeps things
Two more settings describe a store that keeps times or orders names differently from .NET:
TimePrecision: how precisely the store keeps times. A time a test gives, such as an expiry, is expected back cut to this precision. It's exact by default; PostgreSQL's contract tests set a microsecond.NameOrder: the order the store lists names in. It'sStringComparer.Ordinalby default; PostgreSQL's contract tests set Unicode code point order, which is PostgreSQL's byte order.