Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
name: CI

on:
push:
pull_request:

permissions:
contents: read

# There is no FileMaker Server service container, so CI runs the tests that don't need a
# server (query building, and request/response handling against a mocked URLSession). The
# live-server tests skip unless FILEMAKER_TESTS=1.

jobs:
linux:
name: Linux (Swift ${{ matrix.swift }}, ${{ matrix.configuration }})
runs-on: ubuntu-24.04
container: swift:${{ matrix.swift }}-noble
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
# The oldest supported Swift (swift-tools-version 6.2) and the newest.
swift: ["6.2", "6.4"]
configuration: [debug, release]
steps:
- uses: actions/checkout@v7

# Perfect-XML's libxml2 system-library target finds libxml2 through pkg-config on Linux.
- name: Install libxml2
run: |
apt-get update
apt-get install -y --no-install-recommends pkg-config libxml2-dev

- name: Toolchain
run: swift --version

- name: Build (warnings are errors)
run: |
flags="-c ${{ matrix.configuration }} -Xswiftc -warnings-as-errors"
# @testable imports need testing enabled in an optimized build.
[ "${{ matrix.configuration }}" = release ] && flags="$flags -Xswiftc -enable-testing"
echo "FLAGS=$flags" >> "$GITHUB_ENV"
swift build --build-tests $flags

- name: Test
run: swift test $FLAGS

macos:
# libxml2 comes from the macOS SDK; no Homebrew packages are needed.
name: macOS (Xcode ${{ matrix.xcode }}, ${{ matrix.configuration }})
runs-on: macos-26
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
# The oldest Xcode with Swift 6.2, and the runner's default.
xcode: ["26.0.1", "26.6"]
configuration: [debug, release]
steps:
- uses: actions/checkout@v7

- name: Select Xcode
run: |
sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app
swift --version

- name: Build (warnings are errors)
run: |
flags="-c ${{ matrix.configuration }} -Xswiftc -warnings-as-errors"
# @testable imports need testing enabled in an optimized build.
[ "${{ matrix.configuration }}" = release ] && flags="$flags -Xswiftc -enable-testing"
echo "FLAGS=$flags" >> "$GITHUB_ENV"
swift build --build-tests $flags

- name: Test
run: swift test $FLAGS

ios:
name: iOS Simulator
runs-on: macos-26
timeout-minutes: 30
steps:
- uses: actions/checkout@v7

- name: Select Xcode
run: |
sudo xcode-select -s /Applications/Xcode_26.6.app
xcodebuild -version

# On a fresh runner the simulator service can start late, and xcodebuild then lists no
# simulators at all ("Unable to find a device"). Wait until it offers the one we test on.
# Listing devices with simctl makes the service refresh its runtimes
# (actions/runner-images#12948).
- name: Wait for the simulator
run: |
deadline=$((SECONDS + 300))
while [ $SECONDS -lt $deadline ]; do
xcrun simctl list devices available > /dev/null 2>&1 || true
if destinations=$(xcodebuild -showdestinations -scheme PerfectFileMaker) \
&& grep -q "name:iPhone 17 }" <<< "$destinations"; then
exit 0
fi
echo "No iPhone 17 simulator yet; retrying in 5 seconds"
sleep 5
done
echo "No iPhone 17 simulator after 5 minutes"
xcrun simctl list devices available
exit 1

# The library builds at the package's iOS 15 deployment target, so this also catches
# library APIs that need a newer iOS. No SWIFT_TREAT_WARNINGS_AS_ERRORS here: it
# conflicts with Xcode's warning suppression for remote package dependencies
# (Perfect-XML). The SwiftPM jobs above check warnings.
- name: Test
run: |
xcodebuild test -scheme PerfectFileMaker \
-destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' \
-destination-timeout 120
4 changes: 2 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ import PackageDescription

let package = Package(
name: "PerfectFileMaker",
platforms: [.macOS(.v12)],
platforms: [.macOS(.v12), .iOS(.v15)],
products: [
.library(name: "PerfectFileMaker", targets: ["PerfectFileMaker"]),
],
dependencies: [
.package(url: "https://github.com/PerfectlySoft/Perfect-XML.git", branch: "main"),
.package(url: "https://github.com/PerfectlySoft/Perfect-XML.git", from: "4.0.0"),
],
targets: [
.target(
Expand Down
45 changes: 33 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,40 +2,41 @@

<p align="center">
<img src="https://img.shields.io/badge/Swift-6.2-orange.svg?style=flat" alt="Swift 6.2">
<img src="https://img.shields.io/badge/Platforms-macOS%2012%2B-lightgray.svg?style=flat" alt="Platforms macOS 12+">
<img src="https://img.shields.io/badge/Platforms-macOS%2012%2B%20%7C%20iOS%2015%2B%20%7C%20Linux-lightgray.svg?style=flat" alt="Platforms macOS 12+ | iOS 15+ | Linux">
<a href="LICENSE"><img src="https://img.shields.io/badge/License-Apache%202.0-lightgrey.svg?style=flat" alt="License Apache 2.0"></a>
</p>

This project provides access to FileMaker Server databases using the classic XML Custom Web Publishing (CWP) interface (the `fmresultset` grammar) — listing databases, layouts, and fields, and running `find`/`findAll` queries. It was written to be stand-alone and does not need to be run as part of a Perfect server application.
This project provides access to FileMaker Server databases using the classic XML Custom Web Publishing (CWP) interface (the `fmresultset` grammar) — listing databases, layouts, and fields; running `find`, `findAll` and `findAny` queries; and creating, editing, deleting and duplicating records (`.new`, `.edit`, `.delete`, `.duplicate`). It was written to be stand-alone and does not need to be run as part of a Perfect server application.

**Modernized for Swift 6.** A real query-injection gap in the original percent-encoder was found
and fixed during modernization — see the security note in the changelog/commit history if you're
evaluating this as a trust boundary.
and fixed during modernization: search values and names are now percent-encoded with a strict
allow-list, so a value containing `&` or `=` can't add query directives. See the
[4.0.0 release notes](../../releases/tag/4.0.0) if you're evaluating this as a trust boundary.

The pre-Swift-6 version of this package is preserved on the [`legacy`](../../tree/legacy) branch.

## Requirements

- Swift tools version **6.2** (see `Package.swift`'s `swift-tools-version`)
- **macOS 12** or later — this is the only platform formally declared in `Package.swift`'s `platforms` array
- Swift **6.2** or newer (`swift-tools-version: 6.2`)
- **macOS 12** or later, **iOS 15** or later, or **Linux**. On Linux, install `libxml2-dev` and `pkg-config` for Perfect-XML (`sudo apt-get install libxml2-dev pkg-config`); on macOS and iOS the SDK's libxml2 is used and nothing extra is needed.

The source still guards its networking import with `#if canImport(FoundationNetworking)` for portability, but Linux is not currently a declared/supported SPM platform for this package — treat Linux support as unverified rather than assume the old Linux build notes below still apply.
CI builds and tests on Linux (Swift 6.2 and 6.4) and macOS (Xcode 26.0.1 and 26.6), in debug and release with warnings treated as errors, and on the iOS Simulator. There is no FileMaker Server in CI, so it runs the tests that don't need one; see [Testing](#testing).

## Building

```swift
.package(url: "https://github.com/PerfectlySoft/Perfect-FileMaker.git", branch: "main")
.package(url: "https://github.com/PerfectlySoft/Perfect-FileMaker.git", from: "4.0.0")
```

This package's own `Package.swift` resolves its [Perfect-XML](https://github.com/PerfectlySoft/Perfect-XML) dependency the same way (`.package(url:, branch: "main")`) — no monorepo layout is required to build either repo.
and add `.product(name: "PerfectFileMaker", package: "Perfect-FileMaker")` to your target's dependencies.

## Dependencies

- [Perfect-XML](https://github.com/PerfectlySoft/Perfect-XML) — used for parsing the `fmresultset` and `FMPXMLLAYOUT` XML responses.
- [Perfect-XML](https://github.com/PerfectlySoft/Perfect-XML) 4.0.0 or later — used to parse the `fmresultset` XML responses. Every request, including `layoutInfo` (sent as `-view`), asks for the `fmresultset` grammar.

Networking is done directly via Foundation's `URLSession`/`URLRequest` — there is **no Perfect-CURL dependency** and no libcurl requirement. Query requests are deliberately sent as `POST` rather than `GET`, to avoid credential-adjacent query values leaking into URL logs, with a default 5-second request timeout and forced connection closure — added specifically to prevent FileMaker Web Publishing Engine session buildup under crawl-style load.
Networking is done directly via Foundation's `URLSession`/`URLRequest` — there is **no Perfect-CURL dependency** and no libcurl requirement of its own (on Linux, FoundationNetworking itself links libcurl, which the official Swift images already include). Query requests are deliberately sent as `POST` rather than `GET`, to avoid credential-adjacent query values leaking into URL logs, with a default 5-second inactivity timeout (`URLRequest.timeoutInterval`: it fires when no data arrives for 5 seconds, in practice while waiting for the first byte, not after 5 seconds in total) and a `Connection: close` header asking the server to close each connection after its response. Both were added to stop FileMaker Web Publishing Engine sessions building up under crawl-style load.

Note: the classic `FMPXMLLAYOUT` grammar (full layout/value-list introspection beyond field names) is deliberately unimplemented, matching the original PerfectlySoft library's behavior — this is a known, intentional gap rather than an oversight.
Note: only the `fmresultset` grammar is implemented. `layoutInfo` reads the field names, field result types and portal (related-set) fields from the `fmresultset` metadata. The `FMPXMLLAYOUT` grammar (value lists and other layout details) and the `FMPXMLRESULT` grammar are deliberately unimplemented, matching the original PerfectlySoft library.

## Examples

Expand Down Expand Up @@ -191,3 +192,23 @@ do {
print("Got an unexpected error \(e)")
}
```

## Migrating from 3.x

4.0.0 is a major release; the [release notes](../../releases/tag/4.0.0) list every change. The ones most likely to need attention:

* **Toolchain and platforms.** Swift tools 6.2 is required, and the package declares macOS 12+ and iOS 15+ (3.x declared no platforms). Linux builds need `libxml2-dev` and `pkg-config`. Perfect-CURL is no longer used (on Linux, FoundationNetworking still links libcurl itself).
* **`async`/`await` instead of callbacks.** `databaseNames()`, `layoutNames(database:)`, `layoutInfo(database:layout:)` and `query(_:)` are `async throws` and return their result directly. Replace `fms.query(q) { response in let rs = try response() ... }` with `let rs = try await fms.query(q)`.
* **Perfect-CURL is gone** (and with it PerfectLib, which came in through Perfect-CURL). Requests use Foundation's `URLSession`. `FileMakerServer.init` gains optional `useTLS:`, `urlSession:` and `defaultTimeout:` parameters; existing four-argument calls still compile.
* **5-second inactivity timeout.** A request now fails if no data arrives for 5 seconds (in practice, if the first byte takes longer); a response that keeps arriving can take longer in total. 3.x set no timeout of its own. Pass a longer `defaultTimeout:` for servers, large finds or scripted queries that are slow to start responding.
* **Stricter escaping.** Names and values are percent-encoded except for `A-Z a-z 0-9 - . _ ~`, which closes a query-injection hole. 3.x left `= * & ! ( ) ; ' , / ? @` unescaped, so a value containing `&` could add query directives. Operators are unchanged in effect: 4.0 sends `<` and `>` literally where 3.x encoded them, which decodes to the same thing.
* **Plain-HTTP credentials are logged.** Sending a user name and password without TLS (`useTLS` unset and port not 443) still works but writes a warning to standard error.
* **`Sendable`.** The public types are `Sendable`. `FMPQueryField` is `@unchecked Sendable` because its `value` is `Any`: pass only strings and numbers.

## Testing

```
swift test
```

The query-building and request/response tests run anywhere; the request tests use a mocked `URLSession`. The live tests run against the `FMServer_Sample` database on a real FileMaker Server and are skipped unless `FILEMAKER_TESTS=1` is set, with `FILEMAKER_HOST`, `FILEMAKER_PORT`, `FILEMAKER_USER` and `FILEMAKER_PASSWORD` describing the server.
22 changes: 11 additions & 11 deletions Sources/PerfectFileMaker/FMPQuery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import Foundation
/// allow-list (`A-Za-z0-9-._~`) — deliberately narrower than
/// `Foundation`'s `CharacterSet.urlQueryAllowed` (which lets `!$&'()*+,;=`
/// through untouched) and than the original `PerfectLib.stringByEncodingURL`
/// this replaces (which left `& = ! ( ) * ;` unescaped). FileMaker's
/// this replaces (which left `= * & ! ( ) ; ' , / ? @` unescaped). FileMaker's
/// classic XML Web Publishing query grammar treats exactly those
/// characters as structurally significant (`-q1=Name&-q1.value=Value`,
/// compound queries like `-query=(q1,q2);(q3)`) — an unescaped `&` in a
Expand Down Expand Up @@ -145,16 +145,16 @@ public struct FMPQueryField: @unchecked Sendable {
self.op = op
}

/// The operator token is always-ASCII, fixed FileMaker query syntax —
/// never user data — and stays unencoded. Only the actual comparison
/// value is percent-encoded, and only once: encoding the operator
/// characters themselves (`> < = *`) here and relying on the call
/// site to encode this whole string a second time was the original
/// bug (see `FMPQuery.swift`'s `compoundFieldsString`) — under
/// `PerfectLib`'s old encoder, `.greaterThan`/`.lessThanEqual`/etc.
/// (which embed `<`/`>`/`=`, characters that encoder did escape)
/// produced mangled, broken queries. Never feed operator syntax into
/// the encoder.
/// The operator token is fixed ASCII FileMaker query syntax — never
/// user data — and stays unencoded; only the comparison value is
/// percent-encoded. 3.0.1 instead ran PerfectLib's `stringByEncodingURL`
/// once over operator and value together. That left `=` and `*`
/// literal and encoded only `<`/`>` (equivalent once the server
/// form-decodes the body), but it also left `&` in the value literal,
/// which let a value add query directives. Encoding the value on its
/// own with `fmpEscaped` closes that. The operator is left out of the
/// encoder only to keep the query string readable; encoding it would
/// decode to the same thing.
var valueWithOp: String {
let encodedValue = "\(value)".fmpEscaped
switch op {
Expand Down
35 changes: 22 additions & 13 deletions Sources/PerfectFileMaker/FileMakerServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,14 @@ public struct FileMakerServer: Sendable {
/// relying on a magic port number; see `effectiveUseTLS`.
let useTLS: Bool?
let urlSession: URLSession
/// Applied to every request whose call site doesn't pass its own
/// `timeout:` override (see each public method below). No prior
/// version of this library set an explicit request timeout at all —
/// every outbound request silently fell back to `URLSession`'s own
/// default (`timeoutIntervalForRequest`, 60 seconds). Live-confirmed
/// Applied to every request as its `URLRequest.timeoutInterval`. That is
/// an inactivity timeout, not a limit on the whole request: it fires
/// when no data arrives for this long (in practice, while waiting for
/// the first byte of the response), and a response that keeps
/// trickling in can take longer in total. Earlier versions set no
/// timeout of their own: 3.0.1 (Perfect-CURL) set none at all, and
/// pre-release URLSession builds fell back to `URLSession`'s default
/// (`timeoutIntervalForRequest`, 60 seconds). Live-confirmed
/// (2026-07-17) as a real contributor to FileMaker Server Web
/// Publishing Engine session buildup under crawl-style load: a
/// calling application can reasonably give up on a slow response
Expand All @@ -73,10 +76,12 @@ public struct FileMakerServer: Sendable {
/// request kept running in the background regardless, for up to the
/// rest of that 60 seconds, holding its WPE session open the whole
/// time. 5 seconds is a deliberately short default that fails fast
/// for ordinary lookups; call sites with a real reason to expect a
/// slower response (a large-dataset query, or one that triggers a
/// FileMaker script) should pass an explicit longer `timeout:` to
/// the specific call that needs it, not raise this default globally.
/// for ordinary lookups. The public methods take no per-call timeout:
/// for calls with a real reason to expect a slow first byte (a
/// large-dataset query, or one that triggers a FileMaker script), make
/// a second `FileMakerServer` value with a longer `defaultTimeout` and
/// use it for just those calls, rather than raising this default for
/// everything.
public let defaultTimeout: TimeInterval

/// Initialize using a host, port, username and password.
Expand Down Expand Up @@ -127,8 +132,9 @@ public struct FileMakerServer: Sendable {
func makeRequest(url: URL) -> URLRequest {
var request = URLRequest(url: url)
request.httpMethod = "POST"
// No prior version of this library set an explicit timeout — every
// request silently used URLSession's own 60-second default. See
// Earlier versions set no timeout of their own (3.0.1 via Perfect-CURL
// set none; pre-release URLSession builds used URLSession's 60-second
// default). See
// `defaultTimeout`'s own doc comment for why that's a real problem
// under crawl-style load, not just theoretical.
request.timeoutInterval = defaultTimeout
Expand All @@ -143,8 +149,11 @@ public struct FileMakerServer: Sendable {
// (not a tight request loop that benefits from reuse), those
// accumulate as connections FileMaker Server's own admin console
// shows as still "open" long after their one request finished.
// Forcing closure here trades a per-request TCP handshake for
// guaranteed cleanup — the right tradeoff for this workload.
// `Connection: close` asks the server to close the connection
// after its response (URLSession doesn't close the socket itself),
// trading a per-request TCP handshake for cleanup that doesn't
// depend on keep-alive timers — the right tradeoff for this
// workload.
request.setValue("close", forHTTPHeaderField: "Connection")
if !userName.isEmpty {
if !effectiveUseTLS {
Expand Down
Loading
Loading