Skip to content

Commit 1e3f43f

Browse files
Magnus Hartvig Grønbechclaude
andcommitted
Add AL docs for External File Storage - Azure File Service Connector
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent dba2f88 commit 1e3f43f

5 files changed

Lines changed: 386 additions & 0 deletions

File tree

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# External File Storage -- Azure File Service Connector
2+
3+
Azure File Share connector for the External File Storage module. Sister
4+
implementation to the Blob Storage connector -- same interface, same
5+
framework, but the underlying Azure service has fundamentally different
6+
semantics that make this connector simpler in some areas and more complex
7+
in others.
8+
9+
## Why this connector exists
10+
11+
Azure File Shares provide a SMB-compatible file system with real
12+
directories, atomic renames, and a two-step file creation API. The Blob
13+
connector fakes directories with marker files and implements move as
14+
copy-then-delete. This connector gets those things natively from the
15+
Azure File Share REST API, which makes directory operations trivially
16+
correct and file moves atomic.
17+
18+
## Architecture in one paragraph
19+
20+
The enum extension in `ExtFileShareConnector.EnumExt.al` registers the
21+
`"File Share"` value on the framework's connector enum and binds it to the
22+
implementation codeunit `ExtFileShareConnectorImpl` (4570). That codeunit
23+
implements every method of the `"External File Storage Connector"`
24+
interface by delegating to `AFS File Client` from the Azure Storage SDK.
25+
A single table (`Ext. File Share Account`, 4570) stores connection config;
26+
secrets live in IsolatedStorage, never in the database. The wizard page
27+
collects all config in a single step -- no share lookup page.
28+
29+
## Key differences from the Blob connector
30+
31+
- **Directories are real.** CreateDirectory, DeleteDirectory, and
32+
DirectoryExists are single API calls. No marker file management.
33+
- **File creation is two steps.** CreateFile calls both
34+
`AFSFileClient.CreateFile` (allocate the resource) and
35+
`AFSFileClient.PutFileStream` (upload content). The Azure File Share
36+
REST API requires both.
37+
- **Move is atomic.** MoveFile calls `AFSFileClient.RenameFile` -- a
38+
native server-side rename. No copy-then-delete race condition.
39+
- **Copy needs a full URI.** CopyFile constructs
40+
`https://{storageAccount}.file.core.windows.net/{fileShare}/{escapedPath}`
41+
as the source parameter. The Blob connector does not need this.
42+
- **Path length is enforced.** CheckPath rejects paths over 2048
43+
characters (Azure File Share API limit).
44+
- **Simpler wizard.** One page with manual text entry for the file share
45+
name. No container/share lookup interaction.
46+
47+
## What to watch out for
48+
49+
The `DirectoryExists` implementation does not use a metadata call like
50+
`FileExists` does. Instead it calls `ListDirectory` with `MaxResults(1)`
51+
and treats a 404 as "not found." This is because the Azure File Share API
52+
does not expose a directory metadata endpoint the same way it does for
53+
files.
54+
55+
The `CreateFile` two-step pattern means a failure on `PutFileStream`
56+
leaves an allocated but empty file on the share. There is no rollback.
57+
58+
The `Secret` field on the wizard page is a plain `Text` variable marked
59+
`[NonDebuggable]`, not a `SecretText`. It becomes `SecretText` only when
60+
passed into `CreateAccount`. This is the same pattern as the Blob
61+
connector.
62+
63+
## Build and test
64+
65+
CountryCode is `W1`. The test app is
66+
`External File Storage - Azure File Service Connector Tests`
67+
(ID `80ef626f-e8de-4050-b144-0e3d4993a718`), declared in
68+
`internalsVisibleTo` in `app.json`.
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# Business logic
2+
3+
All business logic lives in `ExtFileShareConnectorImpl.Codeunit.al`. The
4+
codeunit is `Access = Internal` -- only the framework and the test app
5+
(via `internalsVisibleTo`) can call it directly.
6+
7+
## InitFileClient -- the gate for every operation
8+
9+
Every file/directory operation goes through `InitFileClient` before
10+
touching Azure. This procedure loads the account record, checks the
11+
`Disabled` flag, retrieves the secret from IsolatedStorage, selects the
12+
auth strategy (SAS or SharedKey), and initializes the `AFS File Client`.
13+
If anything fails here -- missing account, disabled flag, missing secret
14+
-- the operation errors before making any network call.
15+
16+
```mermaid
17+
flowchart TD
18+
A[Any file operation] --> B[InitFileClient]
19+
B --> C{Account exists?}
20+
C -- No --> ERR1[Error: not registered]
21+
C -- Yes --> D{Disabled?}
22+
D -- Yes --> ERR2[Error: account disabled]
23+
D -- No --> E{Auth type?}
24+
E -- SasToken --> F[UseReadySAS]
25+
E -- SharedKey --> G[CreateSharedKey]
26+
F --> H[Initialize AFS File Client]
27+
G --> H
28+
```
29+
30+
## File creation -- the two-step protocol
31+
32+
This is the most important behavioral difference from the Blob connector.
33+
Azure File Share's REST API requires you to first allocate a file resource
34+
(with its size) and then upload the content in a separate call. The Blob
35+
connector does both in a single `PutBlobBlockBlobStream` call.
36+
37+
```mermaid
38+
flowchart TD
39+
A[CreateFile called] --> B[InitFileClient]
40+
B --> C["AFSFileClient.CreateFile(Path, Stream)"]
41+
C --> D{Success?}
42+
D -- No --> ERR[Error]
43+
D -- Yes --> E["AFSFileClient.PutFileStream(Path, Stream)"]
44+
E --> F{Success?}
45+
F -- No --> ERR
46+
F -- Yes --> DONE[Done]
47+
```
48+
49+
The risk here is that if the first call succeeds but the second fails,
50+
you are left with an allocated empty file on the share. There is no
51+
cleanup logic.
52+
53+
## Move vs copy -- atomic vs constructed
54+
55+
MoveFile delegates to `AFSFileClient.RenameFile`, which is a native
56+
server-side rename. This is atomic -- either the rename happens or it
57+
does not. The Blob connector cannot do this because Azure Blob Storage
58+
has no rename API; it must copy then delete, which can leave orphaned
59+
copies if the delete fails.
60+
61+
CopyFile is more complex than you might expect. The Azure File Share copy
62+
API requires the source to be specified as a full URI, not a relative
63+
path. So `CopyFile` calls `CreateUri` to construct
64+
`https://{storageAccount}.file.core.windows.net/{fileShare}/{escapedPath}`,
65+
URL-encoding the source path with `Uri.EscapeDataString()`. The target is
66+
just a path. This asymmetry is an Azure API requirement, not a design
67+
choice.
68+
69+
## Existence checks -- two different strategies
70+
71+
FileExists and DirectoryExists use different approaches because the Azure
72+
File Share API exposes different capabilities for files and directories.
73+
74+
`FileExists` calls `GetFileMetadata` -- a direct metadata lookup on the
75+
file. If it succeeds, the file exists. If the error message contains
76+
`'404'`, the file does not exist. Any other error propagates.
77+
78+
`DirectoryExists` cannot use a metadata call (the API does not support
79+
directory metadata the same way). Instead it calls `ListDirectory` with
80+
`MaxResults(1)` on the target path. A successful response means the
81+
directory exists. A 404 means it does not. This is slightly more
82+
expensive than a metadata call but is the only reliable option.
83+
84+
Both procedures use string matching on `'404'` in the error message to
85+
distinguish "not found" from other failures. This is fragile but matches
86+
the pattern used across all connectors in the framework.
87+
88+
## Directory operations -- no marker files
89+
90+
This is the headline simplification over the Blob connector. Azure File
91+
Shares have real directories, so:
92+
93+
- `CreateDirectory` just calls `AFSFileClient.CreateDirectory(Path)`,
94+
with a pre-check via `DirectoryExists` to give a clean error message
95+
instead of a confusing Azure API error.
96+
- `DeleteDirectory` just calls `AFSFileClient.DeleteDirectory(Path)`. No
97+
need to find and delete marker files.
98+
- `ListDirectories` and `ListFiles` use the same `GetDirectoryContent`
99+
helper, then filter by `Resource Type` (Directory vs File).
100+
101+
## Listing and pagination
102+
103+
`GetDirectoryContent` is the shared listing engine. It initializes the
104+
file client, enforces path constraints via `CheckPath` (trailing slash,
105+
2048 char max), sets `MaxResults(500)` and a continuation marker, then
106+
calls `AFSFileClient.ListDirectory`. The response is validated by
107+
`ValidateListingResponse`, which updates the pagination marker and sets
108+
an end-of-listing flag when the marker is empty.
109+
110+
This is the same marker-based pagination pattern as the Blob connector.
111+
The page size of 500 is hardcoded.
112+
113+
## Account registration wizard
114+
115+
The wizard (`ExtFileShareAccountWizard.Page.al`) is a single-page
116+
NavigatePage. The user fills in account name, storage account name, auth
117+
type, secret, and file share name. There is no share lookup -- the user
118+
must know the file share name. Clicking "Next" calls
119+
`CreateAccount`, which generates a GUID, writes the secret to
120+
IsolatedStorage, and inserts the record.
121+
122+
The "Back" button just closes the page without saving. Validation is
123+
minimal -- `IsAccountValid` checks that three text fields are non-empty.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Data model
2+
3+
## Overview
4+
5+
This connector has the simplest possible data model: one table and one
6+
secret in IsolatedStorage. There are no table extensions, no
7+
relationships to other tables, and no intermediate records.
8+
9+
```mermaid
10+
erDiagram
11+
"Ext. File Share Account" ||--o| IsolatedStorage : "Secret Key GUID"
12+
"Ext. File Share Account" ||--|| "File Account" : "maps to (in-memory)"
13+
"Ext. File Share Account" }|--|| "Ext. File Share Auth. Type" : "Authorization Type"
14+
```
15+
16+
## How secrets work
17+
18+
The table stores a GUID in the `Secret Key` field, not the actual secret.
19+
The real credential (SAS token or shared key) lives in IsolatedStorage at
20+
company scope, keyed by that GUID. This is the standard BC pattern for
21+
credential storage -- the GUID is an opaque handle.
22+
23+
When an account is deleted, the OnDelete trigger purges the
24+
IsolatedStorage entry. When a secret is first set (via `SetSecret`), the
25+
procedure generates a new GUID if one does not already exist, then writes
26+
the secret to IsolatedStorage. There is no migration path for rotating
27+
secrets -- calling `SetSecret` again overwrites the value in place using
28+
the same GUID.
29+
30+
## The "File Account" mapping
31+
32+
`GetAccounts` in the implementation codeunit reads every `Ext. File Share
33+
Account` record and maps it into a temporary `File Account` record that
34+
the framework understands. This mapping happens in memory on every call --
35+
there is no persisted `File Account` table owned by this connector.
36+
37+
## Disabled flag
38+
39+
The `Disabled` boolean is set to true by the environment cleanup
40+
subscriber when a sandbox is created. `InitFileClient` checks this flag
41+
on every operation and errors if the account is disabled. The flag is
42+
user-visible and editable on the account card page, so an admin can
43+
manually re-enable an account in a sandbox if they provide valid
44+
credentials.
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Extensibility
2+
3+
## What you can extend
4+
5+
Almost nothing. This connector is deliberately closed. It is a leaf
6+
implementation of the External File Storage framework -- it consumes an
7+
interface, it does not define one.
8+
9+
The table `Ext. File Share Account` (4570) is extensible by default (no
10+
`Extensible = false`), so you can add fields to it with a table
11+
extension. This is the only real extension point. You might use this to
12+
store additional per-account metadata, but be aware that the wizard and
13+
card pages are both `Extensible = false`, so you cannot add those fields
14+
to the standard UI without building your own page.
15+
16+
## What you cannot extend
17+
18+
- Both pages (`Ext. File Share Account` and `Ext. File Share Account
19+
Wizard`) are marked `Extensible = false`. You cannot add fields,
20+
actions, or layout changes.
21+
- The implementation codeunit is `Access = Internal`. You cannot call its
22+
procedures directly from outside the app (unless you are the test app
23+
declared in `internalsVisibleTo`).
24+
- The auth type enum is `Access = Internal`. You cannot add new
25+
authentication methods via enum extension.
26+
- The connector publishes no events. There are no subscriber hooks for
27+
intercepting or augmenting file operations.
28+
29+
## How to build a different connector
30+
31+
This app is best understood as a reference implementation. If you want to
32+
connect to a different storage backend, you do not extend this app -- you
33+
build a new one that follows the same pattern:
34+
35+
1. Create an enum extension on `"Ext. File Storage Connector"` that adds
36+
your connector value and binds it to your implementation codeunit via
37+
the `Implementation` property.
38+
2. Implement the `"External File Storage Connector"` interface in your
39+
codeunit.
40+
3. Create your own account table, account page, and wizard.
41+
4. Register your connector's permission sets by extending `"File Storage
42+
- Admin"` and `"File Storage - Edit"`.
43+
44+
The framework discovers connectors through the enum, not through any
45+
registration API. Adding a value to the enum is all it takes to appear
46+
in the connector list.
47+
48+
## Permission set structure
49+
50+
The permission sets follow a layered pattern: Objects (execute on table
51+
and pages) is included by Read (select on tabledata), which is included
52+
by Edit (insert/modify/delete on tabledata). The permission set
53+
extensions wire Edit into the framework's `"File Storage - Admin"` set
54+
and Read into `"File Storage - Edit"` set, so framework-level permission
55+
assignments automatically grant the right access to this connector's
56+
objects.
57+
58+
The implicit entitlement (`ExtFileShareConnector.Entitlement.al`) grants
59+
Edit-level access, meaning all SaaS users with the entitlement get full
60+
CRUD on account records without explicit permission set assignment.
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# Patterns
2+
3+
## Interface-based connector registration
4+
5+
The connector registers itself with the framework entirely through an
6+
enum extension. `ExtFileShareConnector.EnumExt.al` adds the `"File
7+
Share"` value to the `"Ext. File Storage Connector"` enum and uses the
8+
`Implementation` property to bind it to `"Ext. File Share Connector
9+
Impl"`. No factory, no registration API, no event subscription -- the
10+
enum is the registry. This is the standard BC pattern for pluggable
11+
implementations and is shared with the Blob, SFTP, and SharePoint
12+
connectors.
13+
14+
## GUID-keyed secret indirection
15+
16+
Credentials never touch a database table. The account table stores only a
17+
GUID in the `Secret Key` field. The actual secret lives in
18+
IsolatedStorage at company scope, keyed by that GUID. This indirection
19+
means the secret is inaccessible to SQL queries, backup restores, or
20+
configuration package exports. The OnDelete trigger cleans up the
21+
IsolatedStorage entry. `SetSecret` is idempotent -- it generates the GUID
22+
once, then overwrites the value in place on subsequent calls.
23+
24+
This is identical to the Blob connector's approach and is the recommended
25+
BC pattern for any credential that must survive data export scenarios.
26+
27+
## Native directory operations (File Share vs Blob)
28+
29+
The most architecturally significant pattern in this connector is what it
30+
does *not* do. The Blob connector has to simulate directories using
31+
marker files because Azure Blob Storage is a flat key-value store. This
32+
connector operates against Azure File Shares, which have a real
33+
hierarchical file system. So `CreateDirectory`, `DeleteDirectory`, and
34+
`DirectoryExists` are trivial single-call operations.
35+
36+
If you are reading this connector alongside the Blob connector, the
37+
absence of marker file management is the main thing to notice. It
38+
simplifies the code dramatically and eliminates an entire class of
39+
consistency bugs (orphaned markers, race conditions on directory
40+
deletion).
41+
42+
## Two-step file creation
43+
44+
Azure File Share REST API requires files to be created in two calls:
45+
first `CreateFile` to allocate the resource on the server (this tells
46+
Azure the expected file size), then `PutFileStream` to upload the actual
47+
content. This is different from Blob Storage's single-call upload and is
48+
a consequence of how Azure File Shares implement SMB-compatible file
49+
semantics.
50+
51+
The pattern introduces a failure window between the two calls. If
52+
allocation succeeds but upload fails, an empty file remains on the share.
53+
The connector does not attempt cleanup in this case.
54+
55+
## Atomic rename for move
56+
57+
`MoveFile` calls `AFSFileClient.RenameFile` -- a native server-side
58+
rename that is atomic. The Blob connector must do copy-then-delete for
59+
the same operation because Azure Blob Storage does not support rename.
60+
The copy-then-delete approach has a failure window where both source and
61+
target exist, or where the copy succeeds but the delete fails, leaving a
62+
duplicate. The File Share connector avoids this entirely.
63+
64+
## 404-string matching for existence checks
65+
66+
Both `FileExists` and `DirectoryExists` detect "not found" by checking
67+
whether the error message string contains `'404'`. This is not
68+
status-code inspection -- it is string matching on the error text
69+
returned by the AFS SDK. The pattern is fragile in theory (a change to
70+
the SDK's error message format would break it) but is used consistently
71+
across all connectors in the framework, so it is effectively a
72+
convention.
73+
74+
## Lazy client initialization
75+
76+
Every public operation calls `InitFileClient` to construct an
77+
`AFS File Client` from scratch. There is no cached client, no connection
78+
pool. Each operation loads the account record, retrieves the secret,
79+
builds the auth object, and initializes the client. This is
80+
stateless-by-design -- it keeps the codeunit free of instance state and
81+
avoids stale-credential bugs, at the cost of repeated IsolatedStorage
82+
reads.
83+
84+
## Environment cleanup hook
85+
86+
The codeunit subscribes to `OnClearCompanyConfig` from the `Environment
87+
Cleanup` codeunit. When a sandbox is created, the subscriber sets
88+
`Disabled = true` on all accounts via `ModifyAll`. This prevents sandbox
89+
environments from accidentally connecting to production storage accounts.
90+
The admin can manually re-enable accounts on the card page after
91+
verifying the credentials are appropriate for the sandbox context.

0 commit comments

Comments
 (0)