diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..0c0b550 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,112 @@ +--- +layout: default +nav_order: 5 +title: Configuration +--- + +## Configuration + +### ConnectionInfo + +All clients require a `ConnectionInfo` object: + +```csharp +var conn = new ConnectionInfo +{ + FmsUri = "https://your-server.com", + Database = "YourDatabase", + Username = "admin", + Password = "password" +}; +``` + +#### REST API Version + +Set `RestTargetVersion` to target a specific Data API version: + +```csharp +conn.RestTargetVersion = RestTargetVersion.v1; // default +conn.RestTargetVersion = RestTargetVersion.v2; +conn.RestTargetVersion = RestTargetVersion.vLatest; +``` + +### Creating a Client Directly + +```csharp +var client = new FileMakerRestClient(new HttpClient(), conn); +``` + +### Using Dependency Injection + +#### Standard (Scoped) Lifetime + +Register `IFileMakerApiClient` with `AddHttpClient` for managed `HttpClient` lifetime: + +```csharp +services.AddSingleton(new ConnectionInfo +{ + FmsUri = "https://your-server.com", + Database = "YourDatabase", + Username = "admin", + Password = "password" +}); + +services.AddHttpClient(); +``` + +This creates a new `FileMakerRestClient` per scope (typically per HTTP request in ASP.NET Core). + +#### Singleton Lifetime + +For better performance when making many Data API calls per request, register as a singleton: + +```csharp +services.AddHttpClient(); // register IHttpClientFactory + +services.AddSingleton(new ConnectionInfo +{ + FmsUri = "https://your-server.com", + Database = "YourDatabase", + Username = "admin", + Password = "password" +}); + +services.AddSingleton(sp => +{ + var hcf = sp.GetRequiredService(); + var ci = sp.GetRequiredService(); + return new FileMakerRestClient(hcf.CreateClient(), ci); +}); +``` + +The singleton approach reuses the FileMaker Data API token across requests, reducing authentication overhead. + +### FileMaker Cloud Authentication + +For FileMaker Cloud (Claris Connect), use `FileMakerCloudAuthTokenProvider`: + +```csharp +var conn = new ConnectionInfo +{ + FmsUri = "https://yourhost.account.filemaker-cloud.com", + Database = "YourDatabase", + Username = "user@domain.com", + Password = "password" +}; + +var client = new FileMakerRestClient( + new HttpClient(), + new FileMakerCloudAuthTokenProvider(conn)); +``` + +`FileMakerCloudAuthTokenProvider` handles authentication via AWS Cognito. The default Cognito settings are pre-configured for FileMaker Cloud: + +| Property | Default | +|---|---| +| `CognitoUserPoolID` | `us-west-2_NqkuZcXQY` | +| `CognitoClientID` | `4l9rvl4mv5es1eep1qe97cautn` | +| `RegionEndpoint` | `us-west-2` | + +Override these on `ConnectionInfo` if your FileMaker Cloud instance uses different values. + +For a full description of using the FileMaker Data API with FileMaker Cloud, see [this discussion](https://github.com/fuzzzerd/fmdata/issues/217#issuecomment-1203202293). diff --git a/docs/containers.md b/docs/containers.md new file mode 100644 index 0000000..618fa10 --- /dev/null +++ b/docs/containers.md @@ -0,0 +1,84 @@ +--- +layout: default +parent: Guide +nav_order: 7 +title: Container Data +--- + +## Container Data + +Container fields in FileMaker store files (images, PDFs, etc.). FMData handles downloading and uploading container data separately from regular field data. + +### Model Setup + +Add a `byte[]` property with `[ContainerDataFor]` referencing the container field's C# property name: + +```csharp +[DataContract(Name = "Documents")] +public class Document +{ + [DataMember] + public string Title { get; set; } + + [DataMember] + public string Attachment { get; set; } // container field + + [ContainerDataFor("Attachment")] // references the C# property name + public byte[] AttachmentData { get; set; } +} +``` + +### Downloading Container Data + +After finding records, use `ProcessContainers` to download all container data for a collection: + +```csharp +var results = await client.FindAsync(new Document { Title = "Report" }); +await client.ProcessContainers(results); +// each result's AttachmentData now contains the file bytes +``` + +For a single record: + +```csharp +await client.ProcessContainer(singleDocument); +``` + +#### Auto-load on Find + +Set `LoadContainerData` on a find request to automatically download container data with the results: + +```csharp +var req = client.GenerateFindRequest(); +req.Layout = "Documents"; +req.AddQuery(new Document { Title = "Report" }, omit: false); +req.LoadContainerData = true; + +var results = await client.SendAsync(req); +// container data is already loaded +``` + +### Uploading Container Data + +Use `UpdateContainerAsync` to upload data to a container field: + +```csharp +var fileBytes = File.ReadAllBytes("report.pdf"); + +await client.UpdateContainerAsync( + "Documents", // layout + recordId, // FileMaker record ID + "Attachment", // container field name + "report.pdf", // file name + fileBytes); // file content +``` + +For repeating container fields, specify the repetition number: + +```csharp +await client.UpdateContainerAsync( + "Documents", recordId, "Attachment", "report.pdf", + repetition: 2, content: fileBytes); +``` + +> **Note:** Creating a record with container data requires two calls — one to create the record, then a second to upload the container data. diff --git a/docs/creating-records.md b/docs/creating-records.md new file mode 100644 index 0000000..f1c5be0 --- /dev/null +++ b/docs/creating-records.md @@ -0,0 +1,80 @@ +--- +layout: default +parent: Guide +nav_order: 3 +title: Creating Records +--- + +## Creating Records + +### Basic Create + +Pass a model instance to `CreateAsync`. The layout is determined by the `[DataContract(Name = "...")]` attribute: + +```csharp +var invoice = new Invoice +{ + InvoiceNumber = "INV-001", + Amount = 150.00m, + Status = "Open" +}; + +var response = await client.CreateAsync(invoice); +``` + +### Layout Override + +Specify a layout explicitly: + +```csharp +var response = await client.CreateAsync("AlternateLayout", invoice); +``` + +### Null and Default Value Control + +By default, properties with `null` or default values are excluded from the request. To include them: + +```csharp +var response = await client.CreateAsync(invoice, + includeNullValues: true, + includeDefaultValues: true); +``` + +### Create with Scripts + +Run FileMaker scripts as part of the create operation. Scripts execute in this order: pre-request, pre-sort, then post-request (after the record is created). + +```csharp +var response = await client.CreateAsync(invoice, + script: "AfterCreate", scriptParameter: "param1", + preRequestScript: "BeforeCreate", preRequestScriptParam: "param2", + preSortScript: "SortSetup", preSortScriptParameter: "param3"); +``` + +### Reading Script Results + +The `ICreateResponse` contains an `ActionResponse` with script results: + +```csharp +var response = await client.CreateAsync(invoice, + script: "AfterCreate", scriptParameter: "param"); + +var scriptResult = response.Response.ScriptResult; +var scriptError = response.Response.ScriptError; // 0 = no error + +var preReqResult = response.Response.ScriptResultPreRequest; +var preSortResult = response.Response.ScriptResultPreSort; +``` + +### Advanced: SendAsync with ICreateRequest + +For full control, build a request manually: + +```csharp +var req = client.GenerateCreateRequest(invoice); +req.Layout = "Invoices"; +req.Script = "AfterCreate"; +req.ScriptParameter = "param"; + +var response = await client.SendAsync(req); +``` diff --git a/docs/deleting-records.md b/docs/deleting-records.md new file mode 100644 index 0000000..8bd2fd8 --- /dev/null +++ b/docs/deleting-records.md @@ -0,0 +1,52 @@ +--- +layout: default +parent: Guide +nav_order: 5 +title: Deleting Records +--- + +## Deleting Records + +### Delete by Model Type + +Delete using the layout from the model's `[DataContract]` attribute: + +```csharp +var response = await client.DeleteAsync(recordId); +``` + +### Delete by Layout and Record ID + +Specify the layout explicitly: + +```csharp +var response = await client.DeleteAsync(recordId, "Invoices"); +``` + +### Delete with Scripts + +Use `SendAsync` with an `IDeleteRequest` to run scripts alongside a delete: + +```csharp +var req = client.GenerateDeleteRequest(); +req.Layout = "Invoices"; +req.RecordId = recordId; +req.Script = "AfterDelete"; +req.ScriptParameter = "param"; +req.PreRequestScript = "BeforeDelete"; +req.PreRequestScriptParameter = "preParam"; + +var response = await client.SendAsync(req); +``` + +### Reading Script Results + +The `IDeleteResponse` contains an `ActionResponse` with script results: + +```csharp +var scriptResult = response.Response.ScriptResult; +var scriptError = response.Response.ScriptError; // 0 = no error + +var preReqResult = response.Response.ScriptResultPreRequest; +var preSortResult = response.Response.ScriptResultPreSort; +``` diff --git a/docs/editing-records.md b/docs/editing-records.md new file mode 100644 index 0000000..5a47c35 --- /dev/null +++ b/docs/editing-records.md @@ -0,0 +1,88 @@ +--- +layout: default +parent: Guide +nav_order: 4 +title: Editing Records +--- + +## Editing Records + +### Basic Edit + +Pass the FileMaker record ID and a model with the fields to update: + +```csharp +var recordId = 42; +var updated = new Invoice { Status = "Closed", Amount = 200.00m }; +var response = await client.EditAsync(recordId, updated); +``` + +### Layout Override + +Specify a layout explicitly: + +```csharp +var response = await client.EditAsync("AlternateLayout", recordId, updated); +``` + +### Null and Default Value Control + +By default, null and default-valued properties are excluded. To include them: + +```csharp +var response = await client.EditAsync(recordId, updated, + includeNullValues: true, + includeDefaultValues: true); +``` + +### Edit with Scripts + +Run FileMaker scripts alongside the edit: + +```csharp +var response = await client.EditAsync(recordId, + script: "AfterEdit", scriptParameter: "param", input: updated); +``` + +### Dictionary-Based Edit + +Edit specific fields without a model class: + +```csharp +var fields = new Dictionary +{ + { "Status", "Closed" }, + { "Notes", "Updated via API" } +}; + +var response = await client.EditAsync(recordId, "Invoices", fields); +``` + +### Reading Script Results + +The `IEditResponse` contains an `ActionResponse` with script results: + +```csharp +var response = await client.EditAsync(recordId, + script: "AfterEdit", scriptParameter: "param", input: updated); + +var scriptResult = response.Response.ScriptResult; +var scriptError = response.Response.ScriptError; // 0 = no error + +var preReqResult = response.Response.ScriptResultPreRequest; +var preSortResult = response.Response.ScriptResultPreSort; +``` + +### Advanced: SendAsync with IEditRequest + +For full control, build a request manually: + +```csharp +var req = client.GenerateEditRequest(updated); +req.Layout = "Invoices"; +req.RecordId = recordId; +req.Script = "AfterEdit"; +req.ScriptParameter = "param"; + +var response = await client.SendAsync(req); +``` diff --git a/docs/error-handling.md b/docs/error-handling.md new file mode 100644 index 0000000..66fe20b --- /dev/null +++ b/docs/error-handling.md @@ -0,0 +1,86 @@ +--- +layout: default +nav_order: 6 +title: Error Handling +--- + +## Error Handling + +### FMDataException + +When the FileMaker Data API returns an error, FMData throws an `FMDataException`: + +```csharp +try +{ + var results = await client.FindAsync(query); +} +catch (FMDataException ex) +{ + Console.WriteLine($"FileMaker error {ex.FMErrorCode}: {ex.FMErrorMessage}"); +} +``` + +`FMDataException` extends `System.Exception` and adds: + +| Property | Type | Description | +|---|---|---| +| `FMErrorCode` | `int` | The FileMaker error code | +| `FMErrorMessage` | `string` | Human-readable description of the error | + +### Common Error Codes + +| Code | Meaning | +|---|---| +| 0 | No error (success) | +| 102 | Field is missing | +| 105 | Layout is missing | +| 106 | Table is missing | +| 401 | No records match the request | +| 500 | Date value does not meet validation | +| 802 | Unable to open the file | +| 952 | Invalid FileMaker Data API token | + +FMData includes a comprehensive dictionary of all FileMaker error codes (0–1715) and their descriptions. + +### How FMData Maps HTTP Responses + +The FileMaker Data API returns standard HTTP status codes, but uses `500 Internal Server Error` for many application-level errors. FMData handles these cases: + +#### Find Operations + +| HTTP Status | FileMaker Code | FMData Behavior | +|---|---|---| +| 200 OK | — | Returns results normally | +| 404 Not Found | — | Returns an empty collection | +| 500 Internal Server Error | 401 | Returns an empty collection (no records found) | +| 500 Internal Server Error | Other | Throws `FMDataException` with code and message | + +#### GetByFileMakerIdAsync + +| HTTP Status | FileMaker Code | FMData Behavior | +|---|---|---| +| 200 OK | — | Returns the record | +| 404 Not Found | — | Returns `null` | +| 500 Internal Server Error | 401 | Returns `null` | +| 500 Internal Server Error | Other | Throws `FMDataException` with code and message | + +#### Create and Edit Operations + +| HTTP Status | FMData Behavior | +|---|---| +| 200 OK | Returns the response | +| 404 Not Found | Returns response with `"404"` error code (edit only) | +| 500 Internal Server Error | Throws `FMDataException` with code and message | + +#### Delete Operations + +| HTTP Status | FMData Behavior | +|---|---| +| 200 OK | Returns the response | +| 404 Not Found | Returns response with `"404"` code and `"Error"` message | +| 500 Internal Server Error | Throws `FMDataException` with code and message | + +### Authentication Errors + +FMData automatically retries requests that receive a `401 Unauthorized` HTTP response by refreshing the authentication token. This handles expired Data API tokens transparently. diff --git a/docs/example-use.md b/docs/example-use.md deleted file mode 100644 index 1eb16d7..0000000 --- a/docs/example-use.md +++ /dev/null @@ -1,241 +0,0 @@ ---- -layout: default -nav_order: 3 -title: Example Use ---- - -## Example Usage - -The recommended way to consume this library is using a strongly typed model as follows. - -Please review the /tests/FMData.Rest.Tests/ project folder for expected usage flows. - -### Setting up your model - -A model should roughly match a table in your solution. Its accessed via layout. - -```csharp -// use the DataContract attribute to link your model to a layout -[DataContract(Name="NameOfYourLayout")] -public class Model -{ - [DataMember] - public string Name { get; set; } - - // if your model name does not match use DataMember - [DataMember(Name="overrideFieldName")] // the internal database field to use - public string Address { get; set; } - - [DataMember] - public string SomeContainerField { get; set; } - - // use the ContainerDataFor attribute to map container data to a byte[] - [ContainerDataFor("SomeContainerField")] // use the name in your C# model - public byte[] DataForSomeContainerField { get; set; } - - // if your model has properties you don't want mapped use - [IgnoreDataMember] // to skip mapping of the field - public string NotNeededField { get; set; } -} -``` - -### Using IHttpClientFactory - -Constructors take an `HttpClient` and you can setup the DI pipeline in Startup.cs like so for standard use: - -```csharp -services.AddSingleton(ci => new FMData.ConnectionInfo -{ - FmsUri = "https://example.com", - Username = "user", - Password = "password", - Database = "FILE_NAME" -}); -services.AddHttpClient(); -``` - -If you prefer to use a singleton instance of `IFileMakerApiClient` you have to do a little bit more work in startup. This can improve performance if you're making lots of hits to the Data API over a single request to your application: - -```csharp -services.AddHttpClient(); // setup IHttpClientFactory in the DI container -services.AddSingleton(ci => new FMData.ConnectionInfo -{ - FmsUri = "https://example.com", - Username = "user", - Password = "password", - Database = "FILE_NAME" -}); -// Keep the FileMaker client as a singleton for speed -services.AddSingleton(s => { - var hcf = s.GetRequiredService(); - var ci = s.GetRequiredService(); - return new FileMakerRestClient(hcf.CreateClient(), ci); -}); -``` - -Behind the scenes, the injected `HttpClient` is kept alive for the lifetime of the FMData client (rest/XML) and reused throughout. This is useful to manage the lifetime of `IFileMakerApiClient` as a singleton, since it stores data about FileMaker Data API tokens and reuses them as much as possible. Simply using `services.AddHttpClient();` keeps the lifetime of our similar to that of a 'managed `HttpClient`' which works for simple scenarios. - -Test both approaches in your solution and use what works. - -### Authentication with FileMaker Cloud - -We can use the `FileMakerRestClient`, when the setup is done. Just create a new `ConnectionInfo` object and set the required properties: - -```cs -var conn = new ConnectionInfo(); -conn.FmsUri = "https://{NAME}.account.filemaker-cloud.com"; -conn.Username = "user@domain.com"; -conn.Password = "********"; -conn.Database = "Reporting"; -``` - -Then instantiate the `FileMakerRestClient` with a `FileMakerCloudAuthTokenProvider` as follows: - -```cs -var fm = new FileMakerRestClient(new HttpClient(), new FileMakerCloudAuthTokenProvider(conn)); -``` - -For a full description of using FileMaker Data API with FileMaker Cloud, [see this comment](https://github.com/fuzzzerd/fmdata/issues/217#issuecomment-1203202293). - -### Performing a Find - -```csharp -var client = new FileMakerRestClient("server", "fileName", "user", "pass"); // without .fmp12 -var toFind = new Model { Name = "someName" }; -var results = await client.FindAsync(toFind); -// results = IEnumerable matching with Name field matching "someName" as a FileMaker FindRequest. -``` - -### Create a new record - -```csharp -var client = new FileMakerRestClient("server", "fileName", "user", "pass"); // without .fmp12 -var toCreate = new Model { Name = "someName", Address = "123 Main Street" }; -var results = await client.CreateAsync(toCreate); -// results is an ICreateResponse which indicates success (0/OK or Failure with FMS code/message) -``` - -### Updating a record - -```csharp -var client = new FileMakerRestClient("server", "fileName", "user", "pass"); // without .fmp12 -var fileMakerRecordId = 1; // this is the value from the calculation: Get(RecordID) -var toUpdate = new Model { Name = "someName", Address = "123 Main Street" }; -var results = await client.EditAsync(fileMakerRecordId, toCreate); -// results is an IEditResponse which indicates success (0/OK or Failure with FMS code/message) -``` - -### Find with FileMaker ID Mapping - -Note you need to add an int property to the Model `public int FileMakerRecordId { get; set; }` and provide the Func to the `FindAsync` method to tell FMData how to map the FileMaker ID returned from the API to your model. - -```csharp -Func FMRecordIdMapper = (o, id) => o.FileMakerRecordId = id; -var client = new FileMakerRestClient("server", "fileName", "user", "pass"); // without .fmp12 -var toFind = new Model { Name = "someName" }; -var results = await client.FindAsync(toFind, FMRecordIdMapper); -// results is IEnumerable matching with Name field matching "someName" as a FileMaker FindRequest. -``` - -### Find with Data Info and Script Results - -```csharp -var toFind = new Model { Name = "someName" }; -var req = new FindRequest() { Layout = layout }; -req.AddQuery(toFind, false); -var (data, info, scriptResponse) = await fdc.SendAsync(req, true); -// scriptResponse.ScriptResult contains the post-request script result -// scriptResponse.ScriptErrorPreRequest / ScriptResultPreRequest for pre-request scripts -// scriptResponse.ScriptErrorPreSort / ScriptResultPreSort for pre-sort scripts -``` - -### Running Scripts with Requests - -All operations (Create, Edit, Delete, Find) return script results when scripts are specified on the request. - -```csharp -// Create with scripts -var response = await client.CreateAsync(input, "MyScript", "param", - "PreRequestScript", "preReqParam", "PreSortScript", "preSortParam"); -// response.Response.ScriptResult, ScriptResultPreRequest, ScriptResultPreSort - -// Edit with scripts -var editResponse = await client.EditAsync(recordId, "MyScript", "param", input); -// editResponse.Response.ScriptResult, ScriptResultPreRequest, ScriptResultPreSort - -// Delete with scripts (via IDeleteRequest) -var deleteReq = client.GenerateDeleteRequest(); -deleteReq.Layout = "layout"; -deleteReq.RecordId = recordId; -deleteReq.Script = "MyScript"; -deleteReq.ScriptParameter = "param"; -var deleteResponse = await client.SendAsync(deleteReq); -// deleteResponse.Response.ScriptResult, ScriptResultPreRequest, ScriptResultPreSort -``` - -Alternatively, if you create a calculated field `Get(RecordID)` and put it on your layout then map it the normal way. - -### Find with Portal Limit and Offset - -By default, the FileMaker Data API limits portal records to 50 per request. You can control per-portal `limit` and `offset` using the fluent `WithPortal` builder or `ConfigurePortal` method on `FindRequest`. - -Use the fluent builder to chain portal configuration: - -```csharp -var req = new FindRequest() { Layout = "layout" }; -req.AddQuery(new Model { Name = "someName" }, false); - -// configure portals with limit and offset -req.WithPortal("RelatedInvoices").Limit(100).Offset(1) - .WithPortal("LineItems").Limit(200); - -var results = await client.SendAsync(req); -``` - -Or use `ConfigurePortal` directly: - -```csharp -var req = new FindRequest() { Layout = "layout" }; -req.AddQuery(new Model { Name = "someName" }, false); -req.ConfigurePortal("RelatedInvoices", limit: 100, offset: 1); -var results = await client.SendAsync(req); -``` - -To include specific portals in the response without setting limits: - -```csharp -var req = new FindRequest() { Layout = "layout" }; -req.AddQuery(new Model { Name = "someName" }, false); -req.IncludePortals("RelatedInvoices", "LineItems"); -var results = await client.SendAsync(req); -``` - -Portal parameters work with both find requests (POST to `_find`) and empty-query get-records requests (GET). - -### Find and load Container Data - -Make sure you use the `[ContainerDataFor("NameOfContainer")]` attribute along with a `byte[]` property for processing of your model. - -```csharp -var client = new FileMakerRestClient("server", "fileName", "user", "pass"); // without .fmp12 -var toFind = new Model { Name = "someName" }; -var results = await client.FindAsync(toFind); -await client.ProcessContainers(results); -// results = IEnumerable matching with Name field matching "someName" as a FileMaker FindRequest. -``` - -### Insert or Update Container Data - -```csharp -// assume recordId = a FileMaker RecordId mapped using FMIdMapper -// assume containerDataByteArray is a byte array with file contents of some sort -var client = new FileMakerRestClient("server", "fileName", "user", "pass"); // without .fmp12 -_client.UpdateContainerAsync( - "layout", - recordId, - "containerFieldName", - "filename.jpg/png/pdf/etc", - containerDataByteArray); -``` - -> *Note: In order to create a record with container data two calls must be made. One that creates the actual record ( see above) and one that updates the container field contents.* diff --git a/docs/finding-records.md b/docs/finding-records.md new file mode 100644 index 0000000..3ea62fa --- /dev/null +++ b/docs/finding-records.md @@ -0,0 +1,131 @@ +--- +layout: default +parent: Guide +nav_order: 2 +title: Finding Records +--- + +## Finding Records + +### Simple Find + +Set properties on a model instance to define search criteria: + +```csharp +var query = new Invoice { Status = "Open" }; +var results = await client.FindAsync(query); +``` + +### Pagination + +Use `skip` and `take` parameters to paginate results: + +```csharp +var results = await client.FindAsync(query, skip: 0, take: 50); +``` + +### Record ID Mapping + +Pass `Func` delegates to map FileMaker's internal record ID and modification ID onto your model: + +```csharp +Func fmIdFunc = (o, id) => o.FileMakerRecordId = id; +Func fmModIdFunc = (o, id) => o.FileMakerModId = id; + +var results = await client.FindAsync(query, skip: 0, take: 50, + script: null, scriptParameter: null, + fmIdFunc: fmIdFunc, fmModIdFunc: fmModIdFunc); +``` + +### Layout Override + +By default, FMData uses the layout from `[DataContract(Name = "...")]`. To query a different layout: + +```csharp +var results = await client.FindAsync("AlternateLayout", query); +``` + +### Find with Scripts + +Run a FileMaker script as part of a find request: + +```csharp +var results = await client.FindAsync(query, skip: 0, take: 100, + script: "AuditLog", scriptParameter: "find", + fmIdFunc: fmIdFunc); +``` + +### Get a Single Record by ID + +Retrieve a single record using its FileMaker record ID: + +```csharp +var record = await client.GetByFileMakerIdAsync(42, fmIdFunc, fmModIdFunc); +``` + +You can also specify a layout override: + +```csharp +var record = await client.GetByFileMakerIdAsync("Invoices", 42, fmIdFunc); +``` + +### Advanced: SendAsync with IFindRequest + +For full control, build an `IFindRequest` directly: + +```csharp +var req = client.GenerateFindRequest(); +req.Layout = "Invoices"; +req.AddQuery(new Invoice { Status = "Open" }, omit: false); + +var results = await client.SendAsync(req); +``` + +#### Omit Queries + +Add queries with `omit: true` to exclude matching records: + +```csharp +req.AddQuery(new Invoice { Status = "Open" }, omit: false); +req.AddQuery(new Invoice { Status = "Cancelled" }, omit: true); +``` + +#### Sorting + +Add sort fields with direction (`"ascend"` or `"descend"`): + +```csharp +req.AddSort("InvoiceDate", "descend"); +req.AddSort("InvoiceNumber", "ascend"); +``` + +### Advanced: SendAsync with DataInfo and Script Results + +Use the `includeDataInfo` overload to get record count metadata and script results alongside your data: + +```csharp +var req = client.GenerateFindRequest(); +req.Layout = "Invoices"; +req.AddQuery(new Invoice { Status = "Open" }, omit: false); + +var (data, dataInfo, scriptResponse) = await client.SendAsync(req, includeDataInfo: true); + +// dataInfo.FoundCount, dataInfo.TotalRecordCount, dataInfo.ReturnedCount +// scriptResponse?.ScriptResult, scriptResponse?.ScriptError +``` + +### Advanced: SendFindRequestAsync + +Use `SendFindRequestAsync` when the request model differs from the response model: + +```csharp +var req = client.GenerateFindRequest(); +req.Layout = "Invoices"; +req.AddQuery(new InvoiceQuery { Status = "Open" }, omit: false); + +Func fmIdFunc = (o, id) => o.RecordId = id; +Func fmModIdFunc = (o, id) => o.ModId = id; + +var (data, dataInfo, scriptResponse) = await client.SendFindRequestAsync( + req, fmIdFunc, fmModIdFunc); +``` diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..3b6b69c --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,102 @@ +--- +layout: default +nav_order: 3 +title: Getting Started +--- + +## Getting Started + +This walkthrough takes you from zero to working code with FMData. + +### 1. Install the Package + +```sh +dotnet add package FMData.Rest +``` + +### 2. Define a Model + +Models map to FileMaker layouts using `DataContract` and `DataMember` attributes from `System.Runtime.Serialization`. + +```csharp +using System.Runtime.Serialization; + +[DataContract(Name = "Contacts")] // FileMaker layout name +public class Contact +{ + [DataMember] + public string FirstName { get; set; } + + [DataMember] + public string LastName { get; set; } + + [DataMember(Name = "Email_Address")] // map to a differently-named field + public string Email { get; set; } + + [IgnoreDataMember] // not sent to FileMaker + public int FileMakerRecordId { get; set; } +} +``` + +### 3. Create a Client + +```csharp +using FMData; +using FMData.Rest; + +var client = new FileMakerRestClient(new HttpClient(), new ConnectionInfo +{ + FmsUri = "https://your-server.com", + Database = "YourDatabase", + Username = "admin", + Password = "password" +}); +``` + +### 4. Find Records + +Set properties on a model instance to define your search criteria, then call `FindAsync`: + +```csharp +var query = new Contact { LastName = "Smith" }; +var results = await client.FindAsync(query); + +foreach (var contact in results) +{ + Console.WriteLine($"{contact.FirstName} {contact.LastName} - {contact.Email}"); +} +``` + +### 5. Create a Record + +```csharp +var newContact = new Contact +{ + FirstName = "Jane", + LastName = "Doe", + Email = "jane@example.com" +}; + +var response = await client.CreateAsync(newContact); +// response.Messages contains the status code and message +``` + +### 6. Edit a Record + +```csharp +var recordId = 42; // the FileMaker record ID +var updated = new Contact { Email = "newemail@example.com" }; +var response = await client.EditAsync(recordId, updated); +``` + +### 7. Delete a Record + +```csharp +await client.DeleteAsync("Contacts", recordId); +``` + +### Next Steps + +- [Guide](guide.html) — Detailed coverage of every operation +- [Configuration](configuration.html) — Dependency injection, authentication, and client lifetime +- [Error Handling](error-handling.html) — Working with FileMaker error codes diff --git a/docs/global-fields.md b/docs/global-fields.md new file mode 100644 index 0000000..ecfbfeb --- /dev/null +++ b/docs/global-fields.md @@ -0,0 +1,23 @@ +--- +layout: default +parent: Guide +nav_order: 9 +title: Global Fields +--- + +## Global Fields + +FileMaker global fields store values that are visible across the current session. FMData provides `SetGlobalFieldAsync` to set these values. + +### Setting a Global Field + +```csharp +var response = await client.SetGlobalFieldAsync( + "BaseTable", // the base table name + "GlobalStatus", // the global field name + "Active"); // the value to set +``` + +The field name should be the fully qualified field name as FileMaker expects it (table occurrence and field name). + +> **Note (XML client):** When using the XML client, global field values are queued and sent with the next request to the server rather than being sent immediately. diff --git a/docs/guide.md b/docs/guide.md new file mode 100644 index 0000000..e5bcfe0 --- /dev/null +++ b/docs/guide.md @@ -0,0 +1,21 @@ +--- +layout: default +nav_order: 4 +title: Guide +has_children: true +--- + +## Guide + +FMData provides a strongly-typed .NET client for the FileMaker Data API. This guide covers every supported operation in detail: + +- **Models & Mapping** — How C# classes map to FileMaker layouts and fields +- **Finding Records** — Simple finds, pagination, sorts, omit queries, and advanced send overloads +- **Creating Records** — Creating records with script support and null-value control +- **Editing Records** — Updating records by ID, including dictionary-based edits +- **Deleting Records** — Deleting records with optional script execution +- **Running Scripts** — Standalone script execution and reading script results from any operation +- **Container Data** — Loading and uploading container field data +- **Portal Data** — Configuring related record portals with limit and offset +- **Global Fields** — Setting global field values +- **Server Metadata** — Querying product info, databases, layouts, scripts, and field metadata diff --git a/docs/installation.md b/docs/installation.md index 4a24b38..4ead895 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -6,8 +6,55 @@ title: Installation ## Installation -Install via `dotnet add` or nuget. Stable releases are on NuGet and CI builds are on MyGet. +Install via `dotnet add` or NuGet. Stable releases are on [NuGet](https://www.nuget.org/packages?q=FMData) and CI builds are on [MyGet](https://www.myget.org/feed/filemaker/package/nuget/FMData). -```ps +### Packages + +**FMData** — Core abstractions and base classes used by all implementations. + +```sh +dotnet add package FMData +``` + +**FMData.Rest** — FileMaker Data API client. This is the package most projects need. + +```sh dotnet add package FMData.Rest ``` + +**FMData.Rest.Auth.FileMakerCloud** — Authentication provider for FileMaker Cloud (Claris Connect) via AWS Cognito. + +```sh +dotnet add package FMData.Rest.Auth.FileMakerCloud +``` + +**FMData.Xml** — Client for the legacy XML/CWP API (experimental). + +```sh +dotnet add package FMData.Xml +``` + +> *Note: If you need full CWP/XML coverage, [check out fmDotNet](https://github.com/fuzzzerd/fmdotnet).* + +### Supported Frameworks + +| Package | Target Frameworks | +|---|---| +| FMData | `net45`, `netstandard1.3`, `netstandard2.0`, `net6.0`, `net8.0` | +| FMData.Rest | `net45`, `netstandard1.3`, `netstandard2.0`, `net6.0`, `net8.0` | +| FMData.Rest.Auth.FileMakerCloud | `netstandard2.0`, `net6.0`, `net8.0` | +| FMData.Xml | `netstandard2.0`, `net6.0`, `net8.0` | + +### Prerelease Builds + +Prerelease packages are published to MyGet. Add the feed to your NuGet sources: + +```sh +dotnet nuget add source https://www.myget.org/F/filemaker/api/v3/index.json -n filemaker-myget +``` + +Then install with the `--prerelease` flag: + +```sh +dotnet add package FMData.Rest --prerelease +``` diff --git a/docs/metadata.md b/docs/metadata.md new file mode 100644 index 0000000..540a12f --- /dev/null +++ b/docs/metadata.md @@ -0,0 +1,104 @@ +--- +layout: default +parent: Guide +nav_order: 10 +title: Server Metadata +--- + +## Server Metadata + +FMData can query FileMaker Server for information about the server, databases, layouts, scripts, and field metadata. + +### Product Information + +```csharp +var info = await client.GetProductInformationAsync(); + +Console.WriteLine(info.Name); // e.g., "FileMaker" +Console.WriteLine(info.Version); // System.Version +Console.WriteLine(info.BuildDate); // DateTime +Console.WriteLine(info.DateFormat); // e.g., "MM/dd/yyyy" +Console.WriteLine(info.TimeFormat); // e.g., "HH:mm:ss" +Console.WriteLine(info.TimeStampFormat); +``` + +### List Databases + +```csharp +IReadOnlyCollection databases = await client.GetDatabasesAsync(); + +foreach (var db in databases) +{ + Console.WriteLine(db); +} +``` + +### List Layouts + +```csharp +IReadOnlyCollection layouts = await client.GetLayoutsAsync(); + +foreach (var layout in layouts) +{ + Console.WriteLine(layout.Name); + + if (layout.IsFolder) + { + foreach (var child in layout.FolderLayoutNames) + { + Console.WriteLine($" {child.Name}"); + } + } +} +``` + +### Get Layout Metadata + +Retrieve detailed field and value list information for a specific layout: + +```csharp +LayoutMetadata meta = await client.GetLayoutAsync("Invoices"); + +// Field metadata +foreach (var field in meta.FieldMetaData) +{ + Console.WriteLine($"{field.Name}: {field.Result} ({field.Type})"); + Console.WriteLine($" AutoEnter: {field.AutoEnter}, NotEmpty: {field.NotEmpty}"); + Console.WriteLine($" Global: {field.Global}, MaxRepeat: {field.MaxRepeat}"); +} + +// Value lists +foreach (var vl in meta.ValueLists) +{ + Console.WriteLine($"Value list: {vl.Name} ({vl.Type})"); + foreach (var item in vl.Values) + { + Console.WriteLine($" {item.DisplayValue} = {item.Value}"); + } +} +``` + +You can optionally pass a record ID to get value lists that depend on a specific record's context: + +```csharp +var meta = await client.GetLayoutAsync("Invoices", recordId: 42); +``` + +### List Scripts + +```csharp +IReadOnlyCollection scripts = await client.GetScriptsAsync(); + +foreach (var script in scripts) +{ + Console.WriteLine(script.Name); + + if (script.IsFolder) + { + foreach (var child in script.FolderScriptNames) + { + Console.WriteLine($" {child.Name}"); + } + } +} +``` diff --git a/docs/models.md b/docs/models.md new file mode 100644 index 0000000..556099a --- /dev/null +++ b/docs/models.md @@ -0,0 +1,97 @@ +--- +layout: default +parent: Guide +nav_order: 1 +title: Models & Mapping +--- + +## Models & Mapping + +FMData uses `System.Runtime.Serialization` attributes to map C# classes to FileMaker layouts and fields. + +### Layout Mapping + +Use `[DataContract]` on your class with `Name` set to the FileMaker layout name: + +```csharp +[DataContract(Name = "Invoices")] +public class Invoice +{ + [DataMember] + public string InvoiceNumber { get; set; } + + [DataMember] + public decimal Amount { get; set; } +} +``` + +### Field Name Overrides + +When your C# property name doesn't match the FileMaker field name, use `DataMember(Name = "...")`: + +```csharp +[DataMember(Name = "Invoice_Number")] +public string InvoiceNumber { get; set; } +``` + +### Ignoring Properties + +Use `[IgnoreDataMember]` to exclude properties from serialization: + +```csharp +[IgnoreDataMember] +public string ComputedValue { get; set; } +``` + +### Container Fields + +Mark a `byte[]` property with `[ContainerDataFor]` to associate it with a container field: + +```csharp +[DataMember] +public string Photo { get; set; } + +[ContainerDataFor("Photo")] // references the C# property name +public byte[] PhotoData { get; set; } +``` + +See [Container Data](containers.html) for loading and uploading container data. + +### Portal Data + +Use `[PortalData]` on a collection property to map related records from a portal: + +```csharp +[PortalData("InvoiceLineItems")] +public IEnumerable LineItems { get; set; } +``` + +By default, FMData prefixes portal field names with the table occurrence name. Set `TablePrefixFieldNames` to control the prefix, or `SkipPrefix = true` to disable it: + +```csharp +[PortalData("InvoiceLineItems", TablePrefixFieldNames = "LineItems", SkipPrefix = false)] +public IEnumerable LineItems { get; set; } +``` + +See [Portal Data](portals.html) for configuring portal requests. + +### FileMaker Record ID Mapping + +FileMaker assigns each record an internal record ID and modification ID. These aren't part of field data, so they're mapped via `Func` delegates at query time rather than attributes: + +```csharp +[IgnoreDataMember] +public int FileMakerRecordId { get; set; } + +[IgnoreDataMember] +public int FileMakerModId { get; set; } +``` + +```csharp +Func fmIdFunc = (o, id) => o.FileMakerRecordId = id; +Func fmModIdFunc = (o, id) => o.FileMakerModId = id; + +var results = await client.FindAsync(query, fmIdFunc); +``` + +Alternatively, add a calculated field `Get(RecordID)` to your FileMaker layout and map it like any other field. diff --git a/docs/portals.md b/docs/portals.md new file mode 100644 index 0000000..b7ba782 --- /dev/null +++ b/docs/portals.md @@ -0,0 +1,102 @@ +--- +layout: default +parent: Guide +nav_order: 8 +title: Portal Data +--- + +## Portal Data + +Portals expose related records from a FileMaker layout. FMData maps portal data onto collection properties in your model. + +### Model Setup + +Use `[PortalData]` on an `IEnumerable` property to map a portal: + +```csharp +[DataContract(Name = "Invoices")] +public class Invoice +{ + [DataMember] + public string InvoiceNumber { get; set; } + + [PortalData("InvoiceLineItems")] + public IEnumerable LineItems { get; set; } +} + +[DataContract(Name = "LineItems")] +public class LineItem +{ + [DataMember] + public string Description { get; set; } + + [DataMember] + public decimal Amount { get; set; } +} +``` + +### Table Prefix and SkipPrefix + +FileMaker's Data API prefixes portal field names with the table occurrence name (e.g., `LineItems::Description`). FMData handles this automatically using the `TablePrefixFieldNames` property: + +```csharp +// Use a custom prefix +[PortalData("InvoiceLineItems", TablePrefixFieldNames = "LineItems")] +public IEnumerable LineItems { get; set; } + +// Skip the prefix entirely (fields come back without table:: prefix) +[PortalData("InvoiceLineItems", SkipPrefix = true)] +public IEnumerable LineItems { get; set; } +``` + +### Including Portals in Requests + +#### Include Specific Portals + +Use `IncludePortals` to specify which portals to return: + +```csharp +var req = client.GenerateFindRequest(); +req.Layout = "Invoices"; +req.AddQuery(new Invoice { InvoiceNumber = "INV-001" }, omit: false); +req.IncludePortals("InvoiceLineItems", "Payments"); + +var results = await client.SendAsync(req); +``` + +#### ConfigurePortal + +Use `ConfigurePortal` to set per-portal limit and offset: + +```csharp +req.ConfigurePortal("InvoiceLineItems", limit: 100, offset: 1); +``` + +#### Fluent WithPortal Builder + +Chain portal configuration using the fluent API: + +```csharp +req.WithPortal("InvoiceLineItems").Limit(100).Offset(1) + .WithPortal("Payments").Limit(50); +``` + +### Portal Limit and Offset + +By default, the FileMaker Data API limits portal records to 50 per request. Use `limit` and `offset` to control pagination: + +- **limit** — Maximum number of portal records to return +- **offset** — Starting position (1-based) in the portal's record set + +```csharp +var req = client.GenerateFindRequest(); +req.Layout = "Invoices"; +req.AddQuery(new Invoice { InvoiceNumber = "INV-001" }, omit: false); + +req.WithPortal("InvoiceLineItems").Limit(200).Offset(1) + .WithPortal("Payments").Limit(100); + +var results = await client.SendAsync(req); +``` + +Portal parameters work with both find requests (POST to `_find`) and get-records requests (GET). diff --git a/docs/resources.md b/docs/resources.md index 3dea2df..5d06938 100644 --- a/docs/resources.md +++ b/docs/resources.md @@ -1,6 +1,6 @@ --- layout: default -nav_order: 4 +nav_order: 7 title: Resources --- diff --git a/docs/scripts.md b/docs/scripts.md new file mode 100644 index 0000000..ae109ba --- /dev/null +++ b/docs/scripts.md @@ -0,0 +1,129 @@ +--- +layout: default +parent: Guide +nav_order: 6 +title: Running Scripts +--- + +## Running Scripts + +FileMaker scripts can be executed standalone or attached to any CRUD operation. + +### Script Execution Order + +When scripts are attached to a request, FileMaker executes them in this order: + +1. **Pre-request script** — runs before the operation +2. **Pre-sort script** — runs after the operation but before sorting +3. **Post-request script** — runs after sorting + +### Standalone Script Execution + +Run a script directly with `RunScriptAsync`: + +```csharp +var result = await client.RunScriptAsync("Invoices", "CleanupScript", "parameter"); +// result is the script result string, or the script error code as a string +``` + +The layout parameter specifies the context layout for the script. The method returns the script result as a string. If the script produces an error, the error code is returned as a string. + +### Attaching Scripts to Requests + +Every request type supports three script slots via properties on `IFileMakerRequest`: + +| Property | Parameter Property | Execution Phase | +|---|---|---| +| `Script` | `ScriptParameter` | After the operation and sort | +| `PreRequestScript` | `PreRequestScriptParameter` | Before the operation | +| `PreSortScript` | `PreSortScriptParameter` | After the operation, before sort | + +#### Create with Scripts + +```csharp +var response = await client.CreateAsync(invoice, + script: "AfterCreate", scriptParameter: "param", + preRequestScript: "BeforeCreate", preRequestScriptParam: "preParam", + preSortScript: "SortSetup", preSortScriptParameter: "sortParam"); +``` + +#### Edit with Scripts + +```csharp +var response = await client.EditAsync(recordId, + script: "AfterEdit", scriptParameter: "param", input: invoice); +``` + +#### Delete with Scripts + +```csharp +var req = client.GenerateDeleteRequest(); +req.Layout = "Invoices"; +req.RecordId = recordId; +req.Script = "AfterDelete"; +req.ScriptParameter = "param"; +var response = await client.SendAsync(req); +``` + +#### Find with Scripts + +```csharp +var results = await client.FindAsync(query, skip: 0, take: 100, + script: "AfterFind", scriptParameter: "param", + fmIdFunc: fmIdFunc); +``` + +### Reading Script Results + +Script results are returned via `ActionResponse`, which contains: + +```csharp +int ScriptError // 0 = success +string ScriptResult // post-request script result +int ScriptErrorPreRequest // 0 = success +string ScriptResultPreRequest +int ScriptErrorPreSort // 0 = success +string ScriptResultPreSort +``` + +#### From Create, Edit, and Delete + +These operations return `ICreateResponse`, `IEditResponse`, or `IDeleteResponse`, each with a `Response` property: + +```csharp +var response = await client.CreateAsync(invoice, + script: "MyScript", scriptParameter: "param"); + +var result = response.Response.ScriptResult; +var error = response.Response.ScriptError; +``` + +#### From Find (SendAsync with DataInfo) + +Use the `includeDataInfo` overload to get script results from find operations: + +```csharp +var req = client.GenerateFindRequest(); +req.Layout = "Invoices"; +req.AddQuery(new Invoice { Status = "Open" }, omit: false); +req.Script = "AfterFind"; +req.ScriptParameter = "param"; + +var (data, dataInfo, scriptResponse) = await client.SendAsync(req, includeDataInfo: true); + +var result = scriptResponse?.ScriptResult; +var error = scriptResponse?.ScriptError; +``` + +### Script Error Handling + +A non-zero `ScriptError` indicates the script encountered an error. Check the error code after any operation that runs scripts: + +```csharp +if (response.Response.ScriptError != 0) +{ + Console.WriteLine($"Script error {response.Response.ScriptError}"); +} +``` + +FileMaker script error codes are the same codes returned by `Get(LastError)` in FileMaker Pro.