├── .changeset
├── README.md
├── config.json
└── fix-layoutmetadata-optional-args.md
├── .eslintrc.cjs
├── .github
├── dependabot.yml
└── workflows
│ └── release.yml
├── .gitignore
├── .npmrc
├── .prettierignore
├── .prettierrc
├── .vscode
└── settings.json
├── CHANGELOG.md
├── LICENSE.md
├── README.md
├── docs
└── methods.md
├── logo-fm.png
├── logo-ts.png
├── op.env
├── package.json
├── pnpm-lock.yaml
├── src
├── adapters
│ ├── core.ts
│ ├── fetch-base-types.ts
│ ├── fetch-base.ts
│ ├── fetch.ts
│ └── otto.ts
├── cli.ts
├── client-types.ts
├── client.ts
├── index.ts
├── tokenStore
│ ├── file.ts
│ ├── index.ts
│ ├── memory.ts
│ ├── types.ts
│ └── upstash.ts
└── utils
│ ├── codegen.ts
│ ├── index.ts
│ ├── typegen
│ ├── buildLayoutClient.ts
│ ├── buildSchema.ts
│ ├── constants.ts
│ ├── getLayoutMetadata.ts
│ ├── index.ts
│ └── types.ts
│ └── utils.ts
├── stubs
├── fmschema.config.stub.js
└── fmschema.config.stub.mjs
├── test
├── client-methods.test.ts
├── fixtures
│ └── test.txt
├── init-client.test.ts
├── setup.ts
├── tokenStorage.test.ts
├── typegen.ts
├── typegen
│ ├── client
│ │ ├── index.ts
│ │ ├── testLayout.ts
│ │ └── weirdPortals.ts
│ ├── testLayout.ts
│ └── weirdPortals.ts
└── zod.test.ts
├── thunder-tests
├── _db-backup
│ ├── thunderCollection.json
│ ├── thunderEnvironment.json
│ └── thunderclient.json
└── thunderActivity.json
├── tsconfig.json
├── vitest.config.ts
└── yarn-error.log
/.changeset/README.md:
--------------------------------------------------------------------------------
1 | # Changesets
2 |
3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
4 | with multi-package repos, or single-package repos to help you version and publish your code. You can
5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets)
6 |
7 | We have a quick list of common questions to get you started engaging with this project in
8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
9 |
--------------------------------------------------------------------------------
/.changeset/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json",
3 | "changelog": "@changesets/cli/changelog",
4 | "commit": false,
5 | "fixed": [],
6 | "linked": [],
7 | "access": "public",
8 | "baseBranch": "main",
9 | "updateInternalDependencies": "patch",
10 | "ignore": []
11 | }
12 |
--------------------------------------------------------------------------------
/.changeset/fix-layoutmetadata-optional-args.md:
--------------------------------------------------------------------------------
1 | ---
2 | "@proofgeist/fmdapi": patch
3 | ---
4 |
5 | Allow `layoutMetadata` to be called without arguments when layout is pre-configured on the client.
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | /** @type {import("eslint").Linter.Config} */
2 | module.exports = {
3 | env: {
4 | browser: true,
5 | es2021: true,
6 | node: true,
7 | },
8 | extends: [
9 | "eslint:recommended",
10 | "plugin:react/recommended",
11 | "plugin:@typescript-eslint/recommended",
12 | ],
13 | overrides: [
14 | {
15 | files: ["**/*.test.js", "**/*.test.jsx"],
16 | env: { jest: true },
17 | },
18 | ],
19 | parser: "@typescript-eslint/parser",
20 | parserOptions: {
21 | ecmaVersion: "latest",
22 | sourceType: "module",
23 | },
24 |
25 | plugins: ["react", "@typescript-eslint"],
26 | rules: {
27 | "@typescript-eslint/no-var-requires": "off",
28 | },
29 | };
30 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
5 |
6 | version: 2
7 | updates:
8 | - package-ecosystem: "npm"
9 | directory: "/" # Location of package manifests
10 | schedule:
11 | interval: "weekly"
12 | ignore:
13 | - dependency-name: chalk
14 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | concurrency: ${{ github.workflow }}-${{ github.ref }}
9 |
10 | jobs:
11 | release:
12 | name: Release
13 | runs-on: ubuntu-latest
14 | strategy:
15 | matrix:
16 | node-version: [20]
17 | steps:
18 | - name: Checkout Repo
19 | uses: actions/checkout@v4
20 | - uses: pnpm/action-setup@v2
21 | with:
22 | version: 8
23 | - name: Use Node.js ${{ matrix.node-version }}
24 | uses: actions/setup-node@v3
25 | with:
26 | node-version: ${{ matrix.node-version }}
27 | cache: "pnpm"
28 | - name: Install dependencies
29 | run: pnpm install
30 |
31 | - name: Create Release Pull Request or Publish to npm
32 | id: changesets
33 | uses: changesets/action@v1
34 | with:
35 | # This expects you to have a script called release which does a build for your packages and calls changeset publish
36 | publish: pnpm release
37 | env:
38 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
39 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
40 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | dist-browser
4 | dist-tokenStore
5 | schema
6 | .env.local
7 | fmschema.config.js
8 | test/codegen/*
9 | test/local.test.ts
10 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | engine-strict=true
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | test/typegen
2 | .changeset
3 | pnpm-lock.yaml
4 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": true,
3 | "singleQuote": false,
4 | "trailingComma": "all",
5 | "printWidth": 80,
6 | "tabWidth": 2
7 | }
8 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {}
2 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # @proofgeist/fmdapi
2 |
3 | ## 4.3.2
4 |
5 | ### Patch Changes
6 |
7 | - 570610f: fix: zod validators weren't running for find requests unless "ignoreEmptyResult" was true
8 |
9 | ## 4.3.1
10 |
11 | ### Patch Changes
12 |
13 | - dfbdcd2: Expose the getToken method for the fetch adapter
14 |
15 | ## 4.3.0
16 |
17 | ### Minor Changes
18 |
19 | - e806a30: New method for uploading container data
20 |
21 | ## 4.2.2
22 |
23 | ### Patch Changes
24 |
25 | - 79547a1: [fix] Removed the redundant setToken call at the end of getToken method
26 |
27 | ## 4.2.1
28 |
29 | ### Patch Changes
30 |
31 | - update maybeFindFirst to include ignore empty result by default
32 |
33 | ## 4.2.0
34 |
35 | ### Minor Changes
36 |
37 | - new method: maybeFindFirst. Will return the first record if it exists, or null without erroring
38 |
39 | ## 4.1.5
40 |
41 | ### Patch Changes
42 |
43 | - fix broken directory link
44 |
45 | ## 4.1.4
46 |
47 | ### Patch Changes
48 |
49 | - Fix sorting query in base fetch adapter
50 |
51 | ## 4.1.3
52 |
53 | ### Patch Changes
54 |
55 | - 3ec78d9: Fix `refreshToken` value in the FetchAdapter
56 |
57 | ## 4.1.2
58 |
59 | ### Patch Changes
60 |
61 | - Added console warning when not using pagination params and not all data is returned
62 |
63 | ## 4.1.1
64 |
65 | ### Patch Changes
66 |
67 | - Allow array of config to support multiple servers in a single config file
68 | - f5751fb: add new option to clean out old files prior to running codegen, so removed schemas don't remain in your codebase
69 |
70 | ## 4.1.0
71 |
72 | ### Minor Changes
73 |
74 | - ca692ee: Rewrote codegen command to use ts-morph instead of typescript. This allows for the script to be run directly from npm, and increses maintainability.
75 | Update tests to vitest
76 |
77 | ## 4.0.2
78 |
79 | ### Patch Changes
80 |
81 | - fix omit type for queries
82 |
83 | ## 4.0.1
84 |
85 | ### Patch Changes
86 |
87 | - eaba131: Fix type import for config file in codegen
88 | - acd66f2: codegen: allow schema names that end with numbers
89 |
90 | ## 4.0.0
91 |
92 | ### Major Changes
93 |
94 | - b6eb3dc: Extendable Adapters
95 |
96 | ### Patch Changes
97 |
98 | - b6eb3dc: Add `layout` property to client return. Use for reference or as a helper to custom method which do not automatically recieve the layout property
99 | - 76f46c9: Support webviewer import in codegen
100 | - b6eb3dc: Remove `baseUrl` from client return
101 |
102 | ## 3.5.0
103 |
104 | ### Minor Changes
105 |
106 | - f7777c1: Support for write operations in FileMaker 2024
107 | - 514b2b6: Set global field values
108 |
109 | ## 3.4.2
110 |
111 | ### Patch Changes
112 |
113 | - 5fe2192: Fix portal range options in query and create/update types
114 | Fixes [#100](https://github.com/proofgeist/fmdapi/issues/100) and [#101](https://github.com/proofgeist/fmdapi/issues/101)
115 |
116 | ## 3.4.0
117 |
118 | ### Minor Changes
119 |
120 | - Handle dateformats option of DataAPI
121 |
122 | ### Patch Changes
123 |
124 | - add more exports to index
125 |
126 | ## 3.3.10
127 |
128 | ### Patch Changes
129 |
130 | - 28067f8: fix list all method
131 |
132 | ## 3.3.8
133 |
134 | ### Patch Changes
135 |
136 | - don't reimport config statement
137 |
138 | ## 3.3.6
139 |
140 | ### Patch Changes
141 |
142 | - fix wv find
143 |
144 | ## 3.3.5
145 |
146 | ### Patch Changes
147 |
148 | - 63f1da5: Update generated comment header
149 |
150 | ## 3.3.4
151 |
152 | ### Patch Changes
153 |
154 | - 7b2cadd: Add type validation helper functions for detecting Otto API keys
155 |
156 | ## 3.3.3
157 |
158 | ### Patch Changes
159 |
160 | - a31c94c: add export for `tokenStores`
161 | adjust offset for automatic pagination functions
162 |
163 | ## 3.3.2
164 |
165 | ### Patch Changes
166 |
167 | - don't rename limit param for find request
168 |
169 | ## 3.3.1
170 |
171 | ### Patch Changes
172 |
173 | - fix offset param in find queries
174 |
175 | ## 3.3.0
176 |
177 | ### Minor Changes
178 |
179 | - Add support for OttoFMS proxy
180 |
181 | ## 3.2.15
182 |
183 | ### Patch Changes
184 |
185 | - e4d536e: Update findAll method similar to listAll method; offset fix
186 | - 08e951d: Fix ReferenceError: \_\_dirname is not defined
187 |
188 | ## 3.2.14
189 |
190 | ### Patch Changes
191 |
192 | - fix: remove offset if 0
193 |
194 | ## 3.2.13
195 |
196 | ### Patch Changes
197 |
198 | - fix: listAll pagination offset
199 |
200 | ## 3.2.10
201 |
202 | ### Patch Changes
203 |
204 | - use absolute imports
205 |
206 | ## 3.2.9
207 |
208 | ### Patch Changes
209 |
210 | - remove node-fetch dep
211 |
212 | ## 3.2.8
213 |
214 | ### Patch Changes
215 |
216 | - update packages
217 |
218 | ## 3.2.7
219 |
220 | ### Patch Changes
221 |
222 | - add types decl to package.json
223 |
224 | ## 3.2.4
225 |
226 | ### Patch Changes
227 |
228 | - improve exports
229 |
230 | ## 3.2.3
231 |
232 | ### Patch Changes
233 |
234 | - add wv path to export
235 | - 4fff462: allow no params to listAll method
236 |
237 | ## 3.2.2
238 |
239 | ### Patch Changes
240 |
241 | - b604cf6: remove webviewer import from main index
242 |
243 | ## 3.2.1
244 |
245 | ### Patch Changes
246 |
247 | - 8146800: add removeFMTableNames to main export
248 |
249 | ## 3.2.0
250 |
251 | ### Minor Changes
252 |
253 | - 30aa8a9: Add WebViewer Client
254 | You can now use easily use this package with FileMaker webviewer integrations! Simply add @proofgeist/fm-webviewer-fetch to your project and specify the FM Script Name that runs the Execute Data API command in the fmschema.config file. Now you'll have autogenerated types for your FileMaker layouts but without sending calls via the network!
255 |
256 | ## 3.1.0
257 |
258 | ### Minor Changes
259 |
260 | - c4f2345: Support portal fields in query type
261 |
262 | ### Patch Changes
263 |
264 | - 8fd05d8: fix: add more error trapping when importing config file in codegen CLI
265 |
266 | ## 3.0.10
267 |
268 | ### Patch Changes
269 |
270 | - 6745dd2: fix Codegen on Windows systems
271 |
272 | ## 3.0.9
273 |
274 | ### Patch Changes
275 |
276 | - fix: remove fetch param from passing through to FM
277 |
278 | ## 3.0.8
279 |
280 | - fix: file return types to conform to zod validator
281 | - fix: if no token store is provided, default memory store was not being imported correctly
282 | - fix: memory token store would throw error during zod validation
283 | - add back default export
284 | - support commonJS and module imports in codegen cli
285 | - improve cli, supports .mjs config file by default
286 | - 129f9a6: fix codegen import
287 |
288 | ## 3.0.0
289 |
290 | ### Major Changes
291 |
292 | - 5c2f0d2: Use native fetch (Node 18+).
293 |
294 | This package now requires Node 18+ and no longer relys on the `node-fetch` package.
295 | Each method supports passing additional options to the `fetch` function via the `fetch` parameter. This is useful if used within a framework that overrides the global `fetch` function (such as Next.js).
296 |
297 | ### Minor Changes
298 |
299 | - 5c2f0d2: Custom functions to override where the temporary access token is stored
300 | - add LocalStorage and Upstash helper methods for token store
301 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Proof+Geist
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | # Claris FileMaker Data API Client for TypeScript
7 |
8 | This package is designed to make working with the FileMaker Data API much easier. Here's just a few key features:
9 |
10 | - Handles token refresh for you automatically
11 | - [Otto](https://ottofms.com/) Data API proxy support
12 | - TypeScript support for easy auto-completion of your fields
13 | - Automated type generation based on layout metadata
14 | - Supports both node and edge runtimes with a customizable token store
15 | - Customizable adapters allow usage even within the [FileMaker WebViewer](https://fm-webviewer-fetch.proofgeist.com/).
16 |
17 | ## Installation
18 |
19 | This library requires zod as a peer depenency and Node 18 or later
20 |
21 | ```sh
22 | npm install @proofgeist/fmdapi zod
23 | ```
24 |
25 | ```sh
26 | yarn add @proofgeist/fmdapi zod
27 | ```
28 |
29 | ## Upgrading to v4
30 |
31 | Version 4 changes the way the client is created to allow for Custom Adapters, but the methods on the client remain the same. If you are using the codegen CLI tool, simply re-run codegen after upgrading to apply the changes.
32 |
33 | ## Quick Start
34 |
35 | > Note: For the best experience, use the [codegen tool](https://github.com/proofgeist/fmdapi/wiki/codegen) to generate layout-specific clients and get autocomplete hints in your IDE with your actual field names. This minimal example just demonstrates the basic setup
36 |
37 | Add the following envnironment variables to your project's `.env` file:
38 |
39 | ```sh
40 | FM_DATABASE=filename.fmp12
41 | FM_SERVER=https://filemaker.example.com
42 |
43 | # if you want to use the OttoFMS Data API Proxy
44 | OTTO_API_KEY=dk_123456...789
45 | # otherwise
46 | FM_USERNAME=admin
47 | FM_PASSWORD=password
48 | ```
49 |
50 | Initialize the client with credentials, depending on your adapter
51 |
52 | ```typescript
53 | // to use the OttoFMS Data API Proxy
54 | import { DataApi, OttoAdapter } from "@proofgeist/fmdapi";
55 | const client = DataApi({
56 | adapter: new OttoAdapter({
57 | auth: { apiKey: process.env.OTTO_API_KEY },
58 | db: process.env.FM_DATABASE,
59 | server: process.env.FM_SERVER,
60 | }),
61 | });
62 | ```
63 |
64 | ```typescript
65 | // to use the raw Data API
66 | import { DataApi, FetchAdapter } from "@proofgeist/fmdapi";
67 | const client = DataApi({
68 | adapter: new FetchAdapter({
69 | auth: {
70 | username: process.env.FM_USERNAME,
71 | password: process.env.FM_PASSWORD,
72 | },
73 | db: process.env.FM_DATABASE,
74 | server: process.env.FM_SERVER,
75 | }),
76 | });
77 | ```
78 |
79 | Then, use the client to query your FileMaker database. [View all available methods here](https://github.com/proofgeist/fmdapi/wiki/methods).
80 |
81 | Basic Example:
82 |
83 | ```typescript
84 | const result = await client.list({ layout: "Contacts" });
85 | ```
86 |
87 | ## TypeScript Support
88 |
89 | The basic client will return the generic FileMaker response object by default. You can also create a type for your exepcted response and get a fully typed response that includes your own fields.
90 |
91 | ```typescript
92 | type TContact = {
93 | name: string;
94 | email: string;
95 | phone: string;
96 | };
97 | const result = await client.list({ layout: "Contacts" });
98 | ```
99 |
100 | 💡 TIP: For a more ergonomic TypeScript experience, use the [included codegen tool](https://github.com/proofgeist/fmdapi/wiki/codegen) to generate these types based on your FileMaker layout metadata.
101 |
102 | For full docs, see the [wiki](https://github.com/proofgeist/fmdapi/wiki)
103 |
104 | ## Edge Runtime Support (v3.0+)
105 |
106 | Since version 3.0 uses the native `fetch` API, it is compatible with edge runtimes, but there are some additional considerations to avoid overwhelming your FileMaker server with too many connections. If you are developing for the edge, be sure to implement one of the following strategies:
107 |
108 | - ✅ Use a custom token store (see above) with a persistent storage method such as Upstash
109 | - ✅ Use a proxy such as the [Otto Data API Proxy](https://www.ottofms.com/docs/otto/working-with-otto/proxy-api-keys/data-api) which handles management of the access tokens itself.
110 | - Providing an API key to the client instead of username/password will automatically use the Otto proxy
111 |
--------------------------------------------------------------------------------
/docs/methods.md:
--------------------------------------------------------------------------------
1 | # Core Functions
2 |
3 | The following methods are available for all adapters.
4 |
5 | - `list` return all records from a given layout
6 | - `find` perform a FileMaker find
7 | - `get` return a single record by recordID
8 | - `create` return a new record
9 | - `update` modify a single record by recordID
10 | - `delete` delete a single record by recordID
11 |
12 | # Helper Functions
13 |
14 | This package also includes some helper methods to make working with Data API responses a little easier:
15 |
16 | - `findOne` return the first record from a find instead of an array. This method will error unless exactly 1 record is found.
17 | - `findFirst` return the first record from a find instead of an array, but will not error if multiple records are found.
18 | - `findAll` return all found records from a find, automatically handling pagination. Use caution with large datasets!
19 | - `listAll` return all records from a given layout, automatically handling pagination. Use caution with large datasets!
20 |
21 | # Adapter-Specific Functions
22 |
23 | The first-party `FetchAdapter` and `OttoAdatper` both share the following additional methods from the `BaseFetchAdapter`:
24 |
25 | - `executeScript` execute a FileMaker script directly
26 | - `layoutMetadata` return metadata for a given layout
27 | - `layouts` return a list of all layouts in the database (top-level layout key ignored)
28 | - `scripts` return a list of all scripts in the database (top-level script key ignored)
29 | - `globals` set global fields for the current session (top-level globals key ignored)
30 |
31 | If you have your own proxy, you can write your own Custom Adapter that extends the BaseFetchAdapter to also implement these methods.
32 |
33 | ## Fetch Adapter
34 |
35 | - `disconnect` forcibly logout of your FileMaker session
36 |
37 | ## Otto Adapter
38 |
39 | No additional methods
40 |
--------------------------------------------------------------------------------
/logo-fm.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/proofgeist/fmdapi/886fc77bfbd9d9014b7068b2e500b1895f4c1fd3/logo-fm.png
--------------------------------------------------------------------------------
/logo-ts.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/proofgeist/fmdapi/886fc77bfbd9d9014b7068b2e500b1895f4c1fd3/logo-ts.png
--------------------------------------------------------------------------------
/op.env:
--------------------------------------------------------------------------------
1 | # __ _____ _
2 | # /_ | __ \ | |
3 | # | | |__) |_ _ ___ _____ _____ _ __ __| | ___ _ ____ __
4 | # | | ___/ _` / __/ __\ \ /\ / / _ \| '__/ _` | / _ \ '_ \ \ / /
5 | # | | | | (_| \__ \__ \\ V V / (_) | | | (_| || __/ | | \ V /
6 | # |_|_| \__,_|___/___/ \_/\_/ \___/|_| \__,_(_)___|_| |_|\_/
7 | #
8 | # This file is intentionally commited to source control.
9 | # It should only reference secrets in 1Password
10 |
11 | DIFFERENT_FM_SERVER="op://xrs5sehh2gm36me62rlfpmsyde/ztcjgvgc4i5aa6cjh5vudpco4m/1Password env Values/FM_SERVER"
12 | DIFFERENT_FM_DATABASE="op://xrs5sehh2gm36me62rlfpmsyde/ztcjgvgc4i5aa6cjh5vudpco4m/1Password env Values/FM_DATABASE"
13 | DIFFERENT_OTTO_API_KEY="op://xrs5sehh2gm36me62rlfpmsyde/ztcjgvgc4i5aa6cjh5vudpco4m/1Password env Values/OTTO_API_KEY"
14 |
15 | FM_SERVER="op://xrs5sehh2gm36me62rlfpmsyde/ztcjgvgc4i5aa6cjh5vudpco4m/1Password env Values/FM_SERVER"
16 | FM_DATABASE="op://xrs5sehh2gm36me62rlfpmsyde/ztcjgvgc4i5aa6cjh5vudpco4m/1Password env Values/FM_DATABASE"
17 | OTTO_API_KEY="op://xrs5sehh2gm36me62rlfpmsyde/ztcjgvgc4i5aa6cjh5vudpco4m/1Password env Values/OTTO_API_KEY"
18 | FM_USERNAME="op://xrs5sehh2gm36me62rlfpmsyde/ztcjgvgc4i5aa6cjh5vudpco4m/username"
19 | FM_PASSWORD="op://xrs5sehh2gm36me62rlfpmsyde/ztcjgvgc4i5aa6cjh5vudpco4m/password"
20 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@proofgeist/fmdapi",
3 | "version": "4.3.2",
4 | "description": "FileMaker Data API client",
5 | "main": "dist/index.js",
6 | "repository": "git@github.com:proofgeist/fm-dapi.git",
7 | "author": "Eric <37158449+eluce2@users.noreply.github.com>",
8 | "license": "MIT",
9 | "private": false,
10 | "type": "module",
11 | "types": "dist/index.d.ts",
12 | "bin": {
13 | "codegen": "dist/cli.js"
14 | },
15 | "exports": {
16 | ".": "./dist/index.js",
17 | "./utils/*": "./dist/utils/*",
18 | "./typegen/*": "./dist/utils/typegen/*",
19 | "./tokenStore/*": "./dist/tokenStore/*",
20 | "./dist/*": "./dist/*",
21 | "./adapters/*": "./dist/adapters/*"
22 | },
23 | "scripts": {
24 | "prepublishOnly": "pnpm run ci",
25 | "build": "tsc",
26 | "check-format": "prettier --check .",
27 | "format": "prettier --write .",
28 | "dev": "tsc --watch",
29 | "ci": "pnpm build && pnpm check-format && pnpm check-exports && pnpm test",
30 | "check-exports": "attw --pack . --ignore-rules=cjs-resolves-to-esm",
31 | "test": "op inject -i op.env -o .env.local -f && vitest run",
32 | "test:codegen": "op inject -i op.env -o .env.local -f && pnpx tsx ./test/typegen.ts",
33 | "changeset": "changeset",
34 | "release": "pnpm build && changeset publish --access public"
35 | },
36 | "dependencies": {
37 | "@changesets/cli": "^2.28.1",
38 | "chalk": "4.1.2",
39 | "commander": "^9.5.0",
40 | "dayjs": "^1.11.13",
41 | "dotenv": "^16.4.7",
42 | "fs-extra": "^11.3.0",
43 | "ts-morph": "^25.0.1",
44 | "ts-toolbelt": "^9.6.0"
45 | },
46 | "peerDependencies": {
47 | "zod": ">=3.21.4"
48 | },
49 | "devDependencies": {
50 | "@arethetypeswrong/cli": "^0.17.4",
51 | "@proofgeist/fm-webviewer-fetch": "^2.1.0",
52 | "@types/fs-extra": "^11.0.3",
53 | "@types/node": "^22.14.0",
54 | "@typescript-eslint/eslint-plugin": "^8.29.0",
55 | "@typescript-eslint/parser": "^8.29.0",
56 | "@upstash/redis": "^1.34.6",
57 | "esbuild-runner": "^2.2.2",
58 | "eslint": "^9.23.0",
59 | "eslint-plugin-react": "^7.37.4",
60 | "prettier": "^3.5.3",
61 | "ts-node": "^10.9.1",
62 | "typescript": "^5.8.2",
63 | "vitest": "^3.1.1",
64 | "zod": "^3.24.2"
65 | },
66 | "engines": {
67 | "node": ">=18.0.0"
68 | },
69 | "files": [
70 | "src",
71 | "dist",
72 | "dist-browser",
73 | "tokenStore",
74 | "utils",
75 | "stubs"
76 | ],
77 | "keywords": [
78 | "filemaker",
79 | "fms",
80 | "fm",
81 | "data api",
82 | "dapi",
83 | "fmrest",
84 | "fmdapi",
85 | "proofgeist",
86 | "fm-dapi"
87 | ],
88 | "pnpm": {
89 | "onlyBuiltDependencies": [
90 | "esbuild"
91 | ]
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/src/adapters/core.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | CreateParams,
3 | CreateResponse,
4 | DeleteParams,
5 | DeleteResponse,
6 | FieldData,
7 | GetParams,
8 | GetResponse,
9 | ListParamsRaw,
10 | LayoutMetadataResponse,
11 | Query,
12 | UpdateParams,
13 | UpdateResponse,
14 | } from "../client-types.js";
15 |
16 | export type BaseRequest = {
17 | layout: string;
18 | fetch?: RequestInit;
19 | timeout?: number;
20 | };
21 |
22 | export type ListOptions = BaseRequest & { data: ListParamsRaw };
23 | export type GetOptions = BaseRequest & {
24 | data: GetParams & { recordId: number };
25 | };
26 | export type FindOptions = BaseRequest & {
27 | data: ListParamsRaw & { query: Array };
28 | };
29 | export type CreateOptions = BaseRequest & {
30 | data: CreateParams & { fieldData: Partial };
31 | };
32 | export type UpdateOptions = BaseRequest & {
33 | data: UpdateParams & { recordId: number; fieldData: Partial };
34 | };
35 | export type DeleteOptions = BaseRequest & {
36 | data: DeleteParams & { recordId: number };
37 | };
38 | export type ContainerUploadOptions = BaseRequest & {
39 | data: {
40 | containerFieldName: string;
41 | repetition?: string | number;
42 | file: Blob;
43 | recordId: string | number;
44 | modId?: number;
45 | };
46 | };
47 |
48 | export type LayoutMetadataOptions = BaseRequest;
49 |
50 | export interface Adapter {
51 | list: (opts: ListOptions) => Promise;
52 | get: (opts: GetOptions) => Promise;
53 | find: (opts: FindOptions) => Promise;
54 | create: (opts: CreateOptions) => Promise;
55 | update: (opts: UpdateOptions) => Promise;
56 | delete: (opts: DeleteOptions) => Promise;
57 | containerUpload: (opts: ContainerUploadOptions) => Promise;
58 |
59 | layoutMetadata: (
60 | opts: LayoutMetadataOptions,
61 | ) => Promise;
62 | }
63 |
--------------------------------------------------------------------------------
/src/adapters/fetch-base-types.ts:
--------------------------------------------------------------------------------
1 | export type BaseFetchAdapterOptions = {
2 | server: string;
3 | db: string;
4 | };
5 | export type GetTokenArguments = { refresh?: boolean };
6 |
--------------------------------------------------------------------------------
/src/adapters/fetch-base.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | AllLayoutsMetadataResponse,
3 | CreateResponse,
4 | DeleteResponse,
5 | GetResponse,
6 | LayoutMetadataResponse,
7 | PortalRanges,
8 | RawFMResponse,
9 | ScriptResponse,
10 | ScriptsMetadataResponse,
11 | UpdateResponse,
12 | } from "../client-types.js";
13 | import { FileMakerError } from "../index.js";
14 | import type {
15 | Adapter,
16 | BaseRequest,
17 | ContainerUploadOptions,
18 | CreateOptions,
19 | DeleteOptions,
20 | FindOptions,
21 | GetOptions,
22 | LayoutMetadataOptions,
23 | ListOptions,
24 | UpdateOptions,
25 | } from "./core.js";
26 | import type {
27 | BaseFetchAdapterOptions,
28 | GetTokenArguments,
29 | } from "./fetch-base-types.js";
30 |
31 | export type ExecuteScriptOptions = BaseRequest & {
32 | script: string;
33 | scriptParam?: string;
34 | };
35 |
36 | export class BaseFetchAdapter implements Adapter {
37 | protected server: string;
38 | protected db: string;
39 | private refreshToken: boolean;
40 | baseUrl: URL;
41 |
42 | constructor(options: BaseFetchAdapterOptions & { refreshToken?: boolean }) {
43 | this.server = options.server;
44 | this.db = options.db;
45 | this.refreshToken = options.refreshToken ?? false;
46 | this.baseUrl = new URL(
47 | `${this.server}/fmi/data/vLatest/databases/${this.db}`,
48 | );
49 |
50 | if (this.db === "") throw new Error("Database name is required");
51 | }
52 |
53 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
54 | protected getToken = async (args?: GetTokenArguments): Promise => {
55 | // method must be implemented in subclass
56 | throw new Error("getToken method not implemented by Fetch Adapter");
57 | };
58 |
59 | protected request = async (params: {
60 | url: string;
61 | body?: object | FormData;
62 | query?: Record;
63 | method?: string;
64 | retry?: boolean;
65 | portalRanges?: PortalRanges;
66 | timeout?: number;
67 | fetchOptions?: RequestInit;
68 | }): Promise => {
69 | const {
70 | query,
71 | body,
72 | method = "GET",
73 | retry = false,
74 | fetchOptions = {},
75 | } = params;
76 |
77 | const url = new URL(`${this.baseUrl}${params.url}`);
78 |
79 | if (query) {
80 | const { _sort, ...rest } = query;
81 | const searchParams = new URLSearchParams(rest);
82 | if (query.portalRanges && typeof query.portalRanges === "object") {
83 | for (const [portalName, value] of Object.entries(
84 | query.portalRanges as PortalRanges,
85 | )) {
86 | if (value) {
87 | value.offset &&
88 | value.offset > 0 &&
89 | searchParams.set(
90 | `_offset.${portalName}`,
91 | value.offset.toString(),
92 | );
93 | value.limit &&
94 | searchParams.set(`_limit.${portalName}`, value.limit.toString());
95 | }
96 | }
97 | }
98 | if (_sort) {
99 | searchParams.set("_sort", JSON.stringify(_sort));
100 | }
101 | searchParams.delete("portalRanges");
102 | url.search = searchParams.toString();
103 | }
104 |
105 | if (body && "portalRanges" in body) {
106 | for (const [portalName, value] of Object.entries(
107 | body.portalRanges as PortalRanges,
108 | )) {
109 | if (value) {
110 | value.offset &&
111 | value.offset > 0 &&
112 | url.searchParams.set(
113 | `_offset.${portalName}`,
114 | value.offset.toString(),
115 | );
116 | value.limit &&
117 | url.searchParams.set(
118 | `_limit.${portalName}`,
119 | value.limit.toString(),
120 | );
121 | }
122 | }
123 | delete body.portalRanges;
124 | }
125 |
126 | const controller = new AbortController();
127 | let timeout: NodeJS.Timeout | null = null;
128 | if (params.timeout)
129 | timeout = setTimeout(() => controller.abort(), params.timeout);
130 |
131 | const token = await this.getToken({ refresh: retry });
132 |
133 | const headers = new Headers(fetchOptions?.headers);
134 | headers.set("Authorization", `Bearer ${token}`);
135 |
136 | // Only set Content-Type for JSON bodies
137 | if (!(body instanceof FormData)) {
138 | headers.set("Content-Type", "application/json");
139 | }
140 |
141 | const res = await fetch(url.toString(), {
142 | ...fetchOptions,
143 | method,
144 | body:
145 | body instanceof FormData
146 | ? body
147 | : body
148 | ? JSON.stringify(body)
149 | : undefined,
150 | headers,
151 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
152 | // @ts-ignore
153 | signal: controller.signal,
154 | });
155 |
156 | if (timeout) clearTimeout(timeout);
157 |
158 | let respData: RawFMResponse;
159 | try {
160 | respData = await res.json();
161 | } catch {
162 | respData = {};
163 | }
164 |
165 | if (!res.ok) {
166 | if (
167 | respData?.messages?.[0].code === "952" &&
168 | !retry &&
169 | this.refreshToken
170 | ) {
171 | // token expired, get new token and retry once
172 | return this.request({ ...params, retry: true });
173 | } else {
174 | throw new FileMakerError(
175 | respData?.messages?.[0].code ?? "500",
176 | `Filemaker Data API failed with (${res.status}): ${JSON.stringify(
177 | respData,
178 | null,
179 | 2,
180 | )}`,
181 | );
182 | }
183 | }
184 |
185 | return respData.response;
186 | };
187 |
188 | public list = async (opts: ListOptions): Promise => {
189 | const { data, layout } = opts;
190 |
191 | const resp = await this.request({
192 | url: `/layouts/${layout}/records`,
193 | query: data as Record,
194 | fetchOptions: opts.fetch,
195 | timeout: opts.timeout,
196 | });
197 | return resp as GetResponse;
198 | };
199 |
200 | public get = async (opts: GetOptions): Promise => {
201 | const { data, layout } = opts;
202 | const resp = await this.request({
203 | url: `/layouts/${layout}/records/${data.recordId}`,
204 | fetchOptions: opts.fetch,
205 | timeout: opts.timeout,
206 | });
207 | return resp as GetResponse;
208 | };
209 |
210 | public find = async (opts: FindOptions): Promise => {
211 | const { data, layout } = opts;
212 | const resp = await this.request({
213 | url: `/layouts/${layout}/_find`,
214 | body: data,
215 | method: "POST",
216 | fetchOptions: opts.fetch,
217 | timeout: opts.timeout,
218 | });
219 | return resp as GetResponse;
220 | };
221 |
222 | public create = async (opts: CreateOptions): Promise => {
223 | const { data, layout } = opts;
224 | const resp = await this.request({
225 | url: `/layouts/${layout}/records`,
226 | body: data,
227 | method: "POST",
228 | fetchOptions: opts.fetch,
229 | timeout: opts.timeout,
230 | });
231 | return resp as CreateResponse;
232 | };
233 |
234 | public update = async (opts: UpdateOptions): Promise => {
235 | const {
236 | data: { recordId, ...data },
237 | layout,
238 | } = opts;
239 | const resp = await this.request({
240 | url: `/layouts/${layout}/records/${recordId}`,
241 | body: data,
242 | method: "PATCH",
243 | fetchOptions: opts.fetch,
244 | timeout: opts.timeout,
245 | });
246 | return resp as UpdateResponse;
247 | };
248 |
249 | public delete = async (opts: DeleteOptions): Promise => {
250 | const { data, layout } = opts;
251 | const resp = await this.request({
252 | url: `/layouts/${layout}/records/${data.recordId}`,
253 | method: "DELETE",
254 | fetchOptions: opts.fetch,
255 | timeout: opts.timeout,
256 | });
257 | return resp as DeleteResponse;
258 | };
259 |
260 | public layoutMetadata = async (
261 | opts: LayoutMetadataOptions,
262 | ): Promise => {
263 | return (await this.request({
264 | url: `/layouts/${opts.layout}`,
265 | fetchOptions: opts.fetch,
266 | timeout: opts.timeout,
267 | })) as LayoutMetadataResponse;
268 | };
269 |
270 | /**
271 | * Execute a script within the database
272 | */
273 | public executeScript = async (opts: ExecuteScriptOptions) => {
274 | const { script, scriptParam, layout } = opts;
275 | const resp = await this.request({
276 | url: `/layouts/${layout}/script/${script}`,
277 | query: scriptParam ? { "script.param": scriptParam } : undefined,
278 | fetchOptions: opts.fetch,
279 | timeout: opts.timeout,
280 | });
281 | return resp as ScriptResponse;
282 | };
283 |
284 | /**
285 | * Returns a list of available layouts on the database.
286 | */
287 | public layouts = async (opts?: Omit) => {
288 | return (await this.request({
289 | url: "/layouts",
290 | fetchOptions: opts?.fetch,
291 | timeout: opts?.timeout,
292 | })) as AllLayoutsMetadataResponse;
293 | };
294 |
295 | /**
296 | * Returns a list of available scripts on the database.
297 | */
298 | public scripts = async (opts?: Omit) => {
299 | return (await this.request({
300 | url: "/scripts",
301 | fetchOptions: opts?.fetch,
302 | timeout: opts?.timeout,
303 | })) as ScriptsMetadataResponse;
304 | };
305 |
306 | public containerUpload = async (opts: ContainerUploadOptions) => {
307 | let url = `/layouts/${opts.layout}/records/${opts.data.recordId}/containers/${opts.data.containerFieldName}`;
308 | if (opts.data.repetition) url += `/${opts.data.repetition}`;
309 | const formData = new FormData();
310 | formData.append("upload", opts.data.file);
311 |
312 | await this.request({
313 | url,
314 | method: "POST",
315 | body: formData,
316 | timeout: opts.timeout,
317 | fetchOptions: opts.fetch,
318 | });
319 | };
320 |
321 | /**
322 | * Set global fields for the current session
323 | */
324 | public globals = async (
325 | opts: Omit & {
326 | globalFields: Record;
327 | },
328 | ) => {
329 | return (await this.request({
330 | url: "/globals",
331 | method: "PATCH",
332 | body: { globalFields: opts.globalFields },
333 | fetchOptions: opts?.fetch,
334 | timeout: opts?.timeout,
335 | })) as Record;
336 | };
337 | }
338 |
--------------------------------------------------------------------------------
/src/adapters/fetch.ts:
--------------------------------------------------------------------------------
1 | import { FileMakerError } from "../index.js";
2 | import memoryStore from "../tokenStore/memory.js";
3 | import type { TokenStoreDefinitions } from "../tokenStore/types.js";
4 | import type {
5 | BaseFetchAdapterOptions,
6 | GetTokenArguments,
7 | } from "./fetch-base-types.js";
8 | import { BaseFetchAdapter } from "./fetch-base.js";
9 |
10 | export interface FetchAdapterOptions extends BaseFetchAdapterOptions {
11 | auth: {
12 | username: string;
13 | password: string;
14 | };
15 | tokenStore?: TokenStoreDefinitions;
16 | }
17 |
18 | export class FetchAdapter extends BaseFetchAdapter {
19 | private username: string;
20 | private password: string;
21 | private tokenStore: Omit;
22 | private getTokenKey: Required["getKey"];
23 |
24 | constructor(args: FetchAdapterOptions) {
25 | super({ ...args, refreshToken: true });
26 | this.username = args.auth.username;
27 | this.password = args.auth.password;
28 | this.tokenStore = args.tokenStore ?? memoryStore();
29 | this.getTokenKey =
30 | args.tokenStore?.getKey ?? (() => `${args.server}/${args.db}`);
31 |
32 | if (this.username === "") throw new Error("Username is required");
33 | if (this.password === "") throw new Error("Password is required");
34 | }
35 |
36 | /**
37 | * Gets a FileMaker Data API token for authentication.
38 | *
39 | * This token is **NOT** guaranteed to be valid, since it expires 15 minutes after the last use. Pass `refresh=true` to forcibly get a fresh token
40 | *
41 | * @param args.refresh - If true, forces getting a new token instead of using cached token
42 | * @internal This method is intended for internal use, you should not need to use it in most cases.
43 | */
44 | public override getToken = async (
45 | args?: GetTokenArguments,
46 | ): Promise => {
47 | const { refresh = false } = args ?? {};
48 | let token: string | null = null;
49 | if (!refresh) {
50 | token = await this.tokenStore.getToken(this.getTokenKey());
51 | }
52 |
53 | if (!token) {
54 | const res = await fetch(`${this.baseUrl}/sessions`, {
55 | method: "POST",
56 | headers: {
57 | "Content-Type": "application/json",
58 | Authorization: `Basic ${Buffer.from(
59 | `${this.username}:${this.password}`,
60 | ).toString("base64")}`,
61 | },
62 | });
63 |
64 | if (!res.ok) {
65 | const data = await res.json();
66 | throw new FileMakerError(
67 | data.messages[0].code,
68 | data.messages[0].message,
69 | );
70 | }
71 | token = res.headers.get("X-FM-Data-Access-Token");
72 | if (!token) throw new Error("Could not get token");
73 | this.tokenStore.setToken(this.getTokenKey(), token);
74 | }
75 |
76 | return token;
77 | };
78 |
79 | public disconnect = async (): Promise => {
80 | const token = await this.tokenStore.getToken(this.getTokenKey());
81 | if (token) {
82 | await this.request({
83 | url: `/sessions/${token}`,
84 | method: "DELETE",
85 | fetchOptions: {
86 | headers: {
87 | Authorization: `Bearer ${token}`,
88 | "Content-Type": "application/json",
89 | },
90 | },
91 | });
92 | this.tokenStore.clearToken(this.getTokenKey());
93 | }
94 | };
95 | }
96 |
--------------------------------------------------------------------------------
/src/adapters/otto.ts:
--------------------------------------------------------------------------------
1 | import { BaseFetchAdapter } from "./fetch-base.js";
2 | import type { BaseFetchAdapterOptions } from "./fetch-base-types.js";
3 |
4 | export type Otto3APIKey = `KEY_${string}`;
5 | export type OttoFMSAPIKey = `dk_${string}`;
6 | export type OttoAPIKey = Otto3APIKey | OttoFMSAPIKey;
7 |
8 | export function isOtto3APIKey(key: string): key is Otto3APIKey {
9 | return key.startsWith("KEY_");
10 | }
11 | export function isOttoFMSAPIKey(key: string): key is OttoFMSAPIKey {
12 | return key.startsWith("dk_");
13 | }
14 | export function isOttoAPIKey(key: string): key is OttoAPIKey {
15 | return isOtto3APIKey(key) || isOttoFMSAPIKey(key);
16 | }
17 |
18 | export function isOttoAuth(auth: unknown): auth is OttoAuth {
19 | if (typeof auth !== "object" || auth === null) return false;
20 | return "apiKey" in auth;
21 | }
22 |
23 | type OttoAuth =
24 | | {
25 | apiKey: Otto3APIKey;
26 | ottoPort?: number;
27 | }
28 | | { apiKey: OttoFMSAPIKey; ottoPort?: never };
29 |
30 | export type OttoAdapterOptions = BaseFetchAdapterOptions & {
31 | auth: OttoAuth;
32 | };
33 |
34 | export class OttoAdapter extends BaseFetchAdapter {
35 | private apiKey: OttoAPIKey | Otto3APIKey;
36 | private port: number | undefined;
37 |
38 | constructor(options: OttoAdapterOptions) {
39 | super({ ...options, refreshToken: false });
40 | this.apiKey = options.auth.apiKey;
41 | this.port = options.auth.ottoPort;
42 |
43 | if (this.apiKey.startsWith("KEY_")) {
44 | // otto v3 uses port 3030
45 | this.baseUrl.port = (this.port ?? 3030).toString();
46 | } else if (this.apiKey.startsWith("dk_")) {
47 | // otto v4 uses default port, but with /otto prefix
48 | this.baseUrl.pathname = `otto/${this.baseUrl.pathname.replace(/^\/+|\/+$/g, "")}`;
49 | } else {
50 | throw new Error(
51 | "Invalid Otto API key format. Must start with 'KEY_' (Otto v3) or 'dk_' (OttoFMS)",
52 | );
53 | }
54 | }
55 |
56 | protected override getToken = async (): Promise => {
57 | return this.apiKey;
58 | };
59 | }
60 |
--------------------------------------------------------------------------------
/src/cli.ts:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | import { program } from "commander";
3 | import chalk from "chalk";
4 | import fs from "fs-extra";
5 | import path from "path";
6 | import { config } from "dotenv";
7 | import { pathToFileURL, fileURLToPath } from "url";
8 | import type { GenerateSchemaOptions } from "./utils/typegen/types.js";
9 | import { generateTypedClients } from "./utils/index.js";
10 |
11 | const defaultConfigPaths = ["./fmschema.config.mjs", "./fmschema.config.js"];
12 | type ConfigArgs = {
13 | configLocation: string;
14 | };
15 |
16 | function init({ configLocation }: ConfigArgs) {
17 | console.log();
18 | if (fs.existsSync(configLocation)) {
19 | console.log(
20 | chalk.yellow(`⚠️ ${path.basename(configLocation)} already exists`),
21 | );
22 | } else {
23 | const stubFile = fs.readFileSync(
24 | path.resolve(
25 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
26 | // @ts-ignore
27 | typeof __dirname !== "undefined"
28 | ? __dirname
29 | : fileURLToPath(new URL(".", import.meta.url)),
30 | "../stubs/fmschema.config.stub.mjs",
31 | ),
32 | "utf8",
33 | );
34 | fs.writeFileSync(configLocation, stubFile, "utf8");
35 | console.log(`✅ Created config file: ${path.basename(configLocation)}`);
36 | }
37 | }
38 |
39 | async function runCodegen({ configLocation }: ConfigArgs) {
40 | if (!fs.existsSync(configLocation)) {
41 | console.error(
42 | chalk.red(
43 | `Could not find ${path.basename(
44 | configLocation,
45 | )} at the root of your project.`,
46 | ),
47 | );
48 | console.log();
49 | console.log("run `codegen --init` to create a new config file");
50 | return process.exit(1);
51 | }
52 | await fs.access(configLocation, fs.constants.R_OK).catch(() => {
53 | console.error(
54 | chalk.red(
55 | `You do not have read access to ${path.basename(
56 | configLocation,
57 | )} at the root of your project.`,
58 | ),
59 | );
60 | return process.exit(1);
61 | });
62 |
63 | let config;
64 |
65 | console.log(`🔍 Reading config from ${configLocation}`);
66 |
67 | if (configLocation.endsWith(".mjs")) {
68 | const module: { config: GenerateSchemaOptions } = await import(
69 | pathToFileURL(configLocation).toString()
70 | );
71 | config = module.config;
72 | } else {
73 | config = require(configLocation);
74 | }
75 |
76 | if (!config) {
77 | console.error(
78 | chalk.red(
79 | `Error reading the config object from ${path.basename(
80 | configLocation,
81 | )}. Are you sure you have a "config" object exported?`,
82 | ),
83 | );
84 | }
85 |
86 | await generateTypedClients(config).catch((err: unknown) => {
87 | console.error(err);
88 | return process.exit(1);
89 | });
90 | console.log(`✅ Generated schemas\n`);
91 | }
92 |
93 | program
94 | .option("--init", "Add the configuration file to your project")
95 | .option("--config ", "optional config file name")
96 | .option("--env-path ", "optional path to your .env file", ".env.local")
97 | .option(
98 | "--skip-env-check",
99 | "Ignore loading environment variables from a file.",
100 | false,
101 | )
102 | .action(async (options) => {
103 | // check if options.config resolves to a file
104 |
105 | const configPath = getConfigPath(options.config);
106 | const configLocation = path.toNamespacedPath(
107 | path.resolve(configPath ?? defaultConfigPaths[0] ?? ""),
108 | );
109 | if (options.init) return init({ configLocation });
110 |
111 | if (!options.skipEnvCheck) {
112 | const envRes = config({ path: options.envPath });
113 | if (envRes.error)
114 | return console.log(
115 | chalk.red(
116 | `Could not resolve your environment variables.\n${envRes.error.message}\n`,
117 | ),
118 | );
119 | }
120 |
121 | // default command
122 | await runCodegen({ configLocation });
123 | });
124 |
125 | program.parse();
126 |
127 | function getConfigPath(configPath?: string): string | null {
128 | if (configPath) {
129 | // If a config path is specified, check if it exists
130 | try {
131 | fs.accessSync(configPath, fs.constants.F_OK);
132 | return configPath;
133 | } catch (e) {
134 | // If it doesn't exist, continue to default paths
135 | }
136 | }
137 |
138 | // Try default paths in order
139 | for (const path of defaultConfigPaths) {
140 | try {
141 | fs.accessSync(path, fs.constants.F_OK);
142 | return path;
143 | } catch (e) {
144 | // If path doesn't exist, try the next one
145 | }
146 | }
147 | return null;
148 | }
149 |
--------------------------------------------------------------------------------
/src/client-types.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | export const ZFieldValue = z.union([z.string(), z.number(), z.null()]);
4 | export type FieldValue = z.infer;
5 |
6 | export const ZFieldData = z.record(z.string(), ZFieldValue);
7 | export type FieldData = z.infer;
8 |
9 | export type ZodGenericPortalData = z.ZodObject<{
10 | [key: string]: z.ZodObject<{ [x: string]: z.ZodString | z.ZodNumber }>;
11 | }>;
12 | export type GenericPortalData = z.infer;
13 |
14 | export type PortalsWithIds = {
15 | [key in keyof U]: Array<
16 | U[key] & {
17 | recordId: string;
18 | modId: string;
19 | }
20 | >;
21 | };
22 | export type UpdatePortalsWithIds<
23 | U extends GenericPortalData = GenericPortalData,
24 | > = {
25 | [key in keyof U]: Array<
26 | U[key] & {
27 | recordId: string;
28 | modId?: string;
29 | }
30 | >;
31 | };
32 |
33 | export const getFMRecordAsZod = <
34 | T extends z.AnyZodObject,
35 | U extends z.AnyZodObject,
36 | >({
37 | fieldData,
38 | portalData,
39 | }: ZInput): z.ZodTypeAny => {
40 | const obj = z.object({
41 | fieldData: fieldData,
42 | recordId: z.string(),
43 | modId: z.string(),
44 | });
45 | if (portalData) {
46 | const portalObj = z.object({});
47 | Object.keys(portalData).forEach((key) => {
48 | portalObj.extend({ [key]: portalData.shape[key] });
49 | });
50 | obj.extend({ portalData: portalObj }).strict();
51 | }
52 | return obj;
53 | };
54 |
55 | export type FMRecord<
56 | T extends FieldData = FieldData,
57 | U extends GenericPortalData = GenericPortalData,
58 | > = {
59 | fieldData: T;
60 | recordId: string;
61 | modId: string;
62 | portalData: PortalsWithIds;
63 | };
64 |
65 | export type ScriptParams = {
66 | script?: string;
67 | "script.param"?: string;
68 | "script.prerequest"?: string;
69 | "script.prerequest.param"?: string;
70 | "script.presort"?: string;
71 | "script.presort.param"?: string;
72 | timeout?: number;
73 | };
74 |
75 | const ZScriptResponse = z.object({
76 | scriptResult: z.string().optional(),
77 | scriptError: z.string().optional(),
78 | "scriptResult.prerequest": z.string().optional(),
79 | "scriptError.prerequest": z.string().optional(),
80 | "scriptResult.presort": z.string().optional(),
81 | "scriptError.presort": z.string().optional(),
82 | });
83 | export type ScriptResponse = z.infer;
84 |
85 | export const ZDataInfo = z.object({
86 | database: z.string(),
87 | layout: z.string(),
88 | table: z.string(),
89 | totalRecordCount: z.number(),
90 | foundCount: z.number(),
91 | returnedCount: z.number(),
92 | });
93 | export type DataInfo = z.infer;
94 |
95 | export type CreateParams =
96 | ScriptParams & { portalData?: UpdatePortalsWithIds };
97 |
98 | export type CreateResponse = ScriptResponse & {
99 | recordId: string;
100 | modId: string;
101 | };
102 |
103 | export type UpdateParams =
104 | CreateParams & {
105 | modId?: number;
106 | };
107 |
108 | export type UpdateResponse = ScriptResponse & {
109 | modId: string;
110 | };
111 |
112 | export type DeleteParams = ScriptParams;
113 |
114 | export type DeleteResponse = ScriptResponse;
115 |
116 | export type RangeParams = {
117 | offset?: number;
118 | limit?: number;
119 | };
120 | export type RangeParamsRaw = {
121 | _offset?: number;
122 | _limit?: number;
123 | };
124 |
125 | export type PortalRanges =
126 | Partial<{ [key in keyof U]: RangeParams }>;
127 |
128 | export type PortalRangesParams<
129 | U extends GenericPortalData = GenericPortalData,
130 | > = {
131 | portalRanges?: PortalRanges;
132 | };
133 |
134 | export type GetParams =
135 | ScriptParams &
136 | PortalRangesParams & {
137 | "layout.response"?: string;
138 | dateformats?: "US" | "file_locale" | "ISO8601";
139 | };
140 |
141 | export type Sort = {
142 | fieldName: keyof T;
143 | // eslint-disable-next-line @typescript-eslint/ban-types
144 | sortOrder?: "ascend" | "descend" | (string & {});
145 | };
146 |
147 | export type ListParams<
148 | T extends FieldData = FieldData,
149 | U extends GenericPortalData = GenericPortalData,
150 | > = GetParams &
151 | RangeParams & {
152 | sort?: Sort | Array>;
153 | };
154 |
155 | export type ListParamsRaw<
156 | T extends FieldData = FieldData,
157 | U extends GenericPortalData = GenericPortalData,
158 | > = GetParams &
159 | RangeParamsRaw & {
160 | _sort?: Array>;
161 | };
162 |
163 | export type GetResponse<
164 | T extends FieldData = FieldData,
165 | U extends GenericPortalData = GenericPortalData,
166 | > = ScriptResponse & {
167 | data: Array>;
168 | dataInfo: DataInfo;
169 | };
170 | export type GetResponseOne<
171 | T extends FieldData = FieldData,
172 | U extends GenericPortalData = GenericPortalData,
173 | > = ScriptResponse & {
174 | data: FMRecord;
175 | dataInfo: DataInfo;
176 | };
177 |
178 | type ZInput = {
179 | fieldData: T;
180 | portalData?: U;
181 | };
182 | export const ZGetResponse = <
183 | T extends z.AnyZodObject,
184 | U extends z.AnyZodObject,
185 | >({
186 | fieldData,
187 | portalData,
188 | }: ZInput): z.ZodType<
189 | GetResponse, z.infer>
190 | // z.ZodTypeDef,
191 | // any
192 | > =>
193 | ZScriptResponse.extend({
194 | data: z.array(getFMRecordAsZod({ fieldData, portalData })),
195 | dataInfo: ZDataInfo,
196 | });
197 | type ZGetResponseReturnType = z.infer>;
198 |
199 | type SecondLevelKeys = {
200 | [K in keyof T]: keyof T[K];
201 | }[keyof T];
202 | export type Query<
203 | T extends FieldData = FieldData,
204 | U extends GenericPortalData = GenericPortalData,
205 | > = Partial<{
206 | [key in keyof T]: T[key] | string;
207 | }> &
208 | Partial<{ [key in SecondLevelKeys]?: string }> & {
209 | omit?: "true";
210 | };
211 |
212 | export type LayoutMetadataResponse = {
213 | fieldMetaData: FieldMetaData[];
214 | portalMetaData: { [key: string]: FieldMetaData[] };
215 | valueLists?: ValueList[];
216 | };
217 | export type ProductInfoMetadataResponse = {
218 | name: string;
219 | dateFormat: string;
220 | timeFormat: string;
221 | timeStampFormat: string;
222 | };
223 | export type DatabaseMetadataResponse = {
224 | databases: Array<{
225 | name: string;
226 | }>;
227 | };
228 |
229 | export type FieldMetaData = {
230 | name: string;
231 | type: "normal" | "calculation" | "summary";
232 | displayType:
233 | | "editText"
234 | | "popupList"
235 | | "popupMenu"
236 | | "checkBox"
237 | | "calendar"
238 | | "radioButtons"
239 | | "secureText";
240 | result: "text" | "number" | "date" | "time" | "timeStamp" | "container";
241 | global: boolean;
242 | autoEnter: boolean;
243 | fourDigitYear: boolean;
244 | maxRepeat: number;
245 | maxCharacters: number;
246 | notEmpty: boolean;
247 | numeric: boolean;
248 | repetitions: number;
249 | timeOfDay: boolean;
250 | valueList?: string;
251 | };
252 |
253 | type ValueList = {
254 | name: string;
255 | // TODO need to test type of value list from other file
256 | type: "customList" | "byField";
257 | values: Array<{ value: string; displayValue: string }>;
258 | };
259 |
260 | /**
261 | * Represents the data returned by a call to the Data API `layouts` endpoint.
262 | */
263 | export type AllLayoutsMetadataResponse = {
264 | /**
265 | * A list of `Layout` or `LayoutsFolder` objects.
266 | */
267 | layouts: LayoutOrFolder[];
268 | };
269 |
270 | /**
271 | * Represents a FileMaker layout.
272 | */
273 | export type Layout = {
274 | /**
275 | * The name of the layout
276 | */
277 | name: string;
278 | /**
279 | * If the node is a layout, `table` may contain the name of the table
280 | * the layout is associated with.
281 | */
282 | table: string;
283 | };
284 |
285 | /**
286 | * Represents a folder of `Layout` or `LayoutsFolder` objects.
287 | */
288 | export type LayoutsFolder = {
289 | /**
290 | * The name of the folder
291 | */
292 | name: string;
293 | isFolder: boolean;
294 | /**
295 | * A list of the Layout or LayoutsFolder objects in the folder.
296 | */
297 | folderLayoutNames?: LayoutOrFolder[];
298 | };
299 |
300 | export type LayoutOrFolder = Layout | LayoutsFolder;
301 |
302 | /**
303 | * Represents the data returned by a call to the Data API `scripts` endpoint.
304 | */
305 | export type ScriptsMetadataResponse = {
306 | /**
307 | * A list of `Layout` or `LayoutsFolder` objects.
308 | */
309 | scripts: ScriptOrFolder[];
310 | };
311 | type Script = {
312 | name: string;
313 | isFolder: false;
314 | };
315 | type ScriptFolder = {
316 | name: string;
317 | isFolder: true;
318 | folderScriptNames: ScriptOrFolder[];
319 | };
320 | export type ScriptOrFolder = Script | ScriptFolder;
321 |
322 | export type RawFMResponse = {
323 | response?: T;
324 | messages?: [{ code: string }];
325 | };
326 |
327 | export class FileMakerError extends Error {
328 | public readonly code: string;
329 |
330 | public constructor(code: string, message: string) {
331 | super(message);
332 | this.code = code;
333 | }
334 | }
335 |
--------------------------------------------------------------------------------
/src/client.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | import type { Adapter } from "./adapters/core.js";
3 | import type {
4 | CreateParams,
5 | CreateResponse,
6 | DeleteParams,
7 | DeleteResponse,
8 | FMRecord,
9 | FieldData,
10 | GenericPortalData,
11 | GetParams,
12 | GetResponse,
13 | GetResponseOne,
14 | ListParams,
15 | Query,
16 | UpdateParams,
17 | UpdateResponse,
18 | } from "./client-types.js";
19 | import { ZGetResponse } from "./client-types.js";
20 | import { FileMakerError } from "./index.js";
21 |
22 | function asNumber(input: string | number): number {
23 | return typeof input === "string" ? parseInt(input) : input;
24 | }
25 |
26 | export type ClientObjectProps = {
27 | /**
28 | * The layout to use by default for all requests. Can be overrridden on each request.
29 | */
30 | layout?: string;
31 | zodValidators?: {
32 | fieldData: z.AnyZodObject;
33 | portalData?: z.AnyZodObject;
34 | };
35 | };
36 |
37 | type WithLayout = {
38 | /**
39 | * The layout to use for the request.
40 | */
41 | layout: string;
42 | };
43 |
44 | type FetchOptions = {
45 | fetch?: RequestInit;
46 | };
47 |
48 | function DataApi<
49 | Opts extends ClientObjectProps = ClientObjectProps,
50 | Td extends FieldData = FieldData,
51 | Ud extends GenericPortalData = GenericPortalData,
52 | Adp extends Adapter = Adapter,
53 | >(options: Opts & { adapter: Adp }) {
54 | const zodTypes = options.zodValidators;
55 | const {
56 | create,
57 | delete: _adapterDelete,
58 | find,
59 | get,
60 | list,
61 | update,
62 | layoutMetadata,
63 | containerUpload,
64 | ...otherMethods
65 | } = options.adapter;
66 |
67 | type CreateArgs = CreateParams & {
68 | fieldData: Partial;
69 | };
70 | type GetArgs = GetParams & {
71 | recordId: number | string;
72 | };
73 | type UpdateArgs = UpdateParams & {
74 | fieldData: Partial;
75 | recordId: number | string;
76 | };
77 | type ContainerUploadArgs = {
78 | containerFieldName: keyof T;
79 | containerFieldRepetition?: string | number;
80 | file: Blob;
81 | recordId: number | string;
82 | modId?: number;
83 | timeout?: number;
84 | };
85 | type DeleteArgs = DeleteParams & {
86 | recordId: number | string;
87 | };
88 | type IgnoreEmptyResult = {
89 | /**
90 | * If true, a find that returns no results will retun an empty array instead of throwing an error.
91 | * @default false
92 | */
93 | ignoreEmptyResult?: boolean;
94 | };
95 | type FindArgs = ListParams<
96 | T,
97 | U
98 | > & {
99 | query: Query | Array>;
100 | timeout?: number;
101 | };
102 |
103 | /**
104 | * List all records from a given layout, no find criteria applied.
105 | */
106 | async function _list(): Promise>;
107 | async function _list(
108 | args: Opts["layout"] extends string
109 | ? ListParams & Partial & FetchOptions
110 | : ListParams & WithLayout & FetchOptions,
111 | ): Promise>;
112 | async function _list(
113 | args?: Opts["layout"] extends string
114 | ? ListParams & Partial & FetchOptions
115 | : ListParams & WithLayout & FetchOptions,
116 | ): Promise> {
117 | const { layout = options.layout, fetch, timeout, ...params } = args ?? {};
118 | if (layout === undefined) throw new Error("Layout is required");
119 |
120 | // rename and refactor limit, offset, and sort keys for this request
121 | if ("limit" in params && params.limit !== undefined)
122 | delete Object.assign(params, { _limit: params.limit })["limit"];
123 | if ("offset" in params && params.offset !== undefined) {
124 | if (params.offset <= 1) delete params.offset;
125 | else delete Object.assign(params, { _offset: params.offset })["offset"];
126 | }
127 | if ("sort" in params && params.sort !== undefined)
128 | delete Object.assign(params, {
129 | _sort: Array.isArray(params.sort) ? params.sort : [params.sort],
130 | })["sort"];
131 |
132 | const result = await list({
133 | layout,
134 | data: params,
135 | fetch,
136 | timeout,
137 | });
138 |
139 | if (result.dataInfo.foundCount > result.dataInfo.returnedCount) {
140 | // more records found than returned
141 | if (args?.limit === undefined && args?.offset === undefined) {
142 | // and the user didn't specify a limit or offset, so we should warn them
143 | console.warn(
144 | `🚨 @proofgeist/fmdapi: Loaded only ${result.dataInfo.returnedCount} of the ${result.dataInfo.foundCount} records from your "${layout}" layout. Use the "listAll" method to automatically paginate through all records, or specify a "limit" and "offset" to handle pagination yourself.`,
145 | );
146 | }
147 | }
148 |
149 | if (zodTypes) ZGetResponse(zodTypes).parse(result);
150 | return result as GetResponse;
151 | }
152 |
153 | /**
154 | * Paginate through all records from a given layout, no find criteria applied.
155 | * ⚠️ WARNING: Use this method with caution, as it can be slow with large datasets
156 | */
157 | async function listAll<
158 | T extends FieldData = Td,
159 | U extends Ud = Ud,
160 | >(): Promise[]>;
161 | async function listAll(
162 | args: Opts["layout"] extends string
163 | ? ListParams & Partial & FetchOptions
164 | : ListParams & WithLayout & FetchOptions,
165 | ): Promise[]>;
166 | async function listAll(
167 | args?: Opts["layout"] extends string
168 | ? ListParams & Partial & FetchOptions
169 | : ListParams & WithLayout & FetchOptions,
170 | ): Promise[]> {
171 | let runningData: GetResponse["data"] = [];
172 | const limit = args?.limit ?? 100;
173 | let offset = args?.offset ?? 1;
174 |
175 | // eslint-disable-next-line no-constant-condition
176 | while (true) {
177 | const data = (await _list({
178 | ...args,
179 | offset,
180 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
181 | } as any)) as unknown as GetResponse;
182 | runningData = [...runningData, ...data.data];
183 | if (runningData.length >= data.dataInfo.foundCount) break;
184 | offset = offset + limit;
185 | }
186 | return runningData;
187 | }
188 |
189 | /**
190 | * Create a new record in a given layout
191 | */
192 | async function _create(
193 | args: Opts["layout"] extends string
194 | ? CreateArgs & Partial & FetchOptions
195 | : CreateArgs & WithLayout & FetchOptions,
196 | ): Promise {
197 | const { layout = options.layout, fetch, timeout, ...params } = args ?? {};
198 | if (layout === undefined) throw new Error("Layout is required");
199 | return await create({ layout, data: params, fetch, timeout });
200 | }
201 |
202 | /**
203 | * Get a single record by Internal RecordId
204 | */
205 | async function _get(
206 | args: Opts["layout"] extends string
207 | ? GetArgs & Partial & FetchOptions
208 | : GetArgs & WithLayout & FetchOptions,
209 | ): Promise> {
210 | args.recordId = asNumber(args.recordId);
211 | const {
212 | recordId,
213 | layout = options.layout,
214 | fetch,
215 | timeout,
216 | ...params
217 | } = args;
218 | if (layout === undefined) throw new Error("Layout is required");
219 |
220 | const data = await get({
221 | layout,
222 | data: { ...params, recordId },
223 | fetch,
224 | timeout,
225 | });
226 | if (zodTypes)
227 | return ZGetResponse(zodTypes).parse(data) as GetResponse;
228 | return data as GetResponse;
229 | }
230 |
231 | /**
232 | * Update a single record by internal RecordId
233 | */
234 | async function _update(
235 | args: Opts["layout"] extends string
236 | ? UpdateArgs & Partial & FetchOptions
237 | : UpdateArgs & WithLayout & FetchOptions,
238 | ): Promise {
239 | args.recordId = asNumber(args.recordId);
240 | const {
241 | recordId,
242 | layout = options.layout,
243 | fetch,
244 | timeout,
245 | ...params
246 | } = args;
247 | if (layout === undefined) throw new Error("Layout is required");
248 | return await update({
249 | layout,
250 | data: { ...params, recordId },
251 | fetch,
252 | timeout,
253 | });
254 | }
255 |
256 | /**
257 | * Delete a single record by internal RecordId
258 | */
259 | async function deleteRecord(
260 | args: Opts["layout"] extends string
261 | ? DeleteArgs & Partial & FetchOptions
262 | : DeleteArgs & WithLayout & FetchOptions,
263 | ): Promise {
264 | args.recordId = asNumber(args.recordId);
265 | const {
266 | recordId,
267 | layout = options.layout,
268 | fetch,
269 | timeout,
270 | ...params
271 | } = args;
272 | if (layout === undefined) throw new Error("Layout is required");
273 |
274 | return _adapterDelete({
275 | layout,
276 | data: { ...params, recordId },
277 | fetch,
278 | timeout,
279 | });
280 | }
281 |
282 | /**
283 | * Find records in a given layout
284 | */
285 | async function _find(
286 | args: Opts["layout"] extends string
287 | ? FindArgs & IgnoreEmptyResult & Partial & FetchOptions
288 | : FindArgs & IgnoreEmptyResult & WithLayout & FetchOptions,
289 | ): Promise> {
290 | const {
291 | query: queryInput,
292 | layout = options.layout,
293 | ignoreEmptyResult = false,
294 | timeout,
295 | fetch,
296 | ...params
297 | } = args;
298 | const query = !Array.isArray(queryInput) ? [queryInput] : queryInput;
299 | if (layout === undefined) throw new Error("Layout is required");
300 |
301 | // rename and refactor limit, offset, and sort keys for this request
302 | if ("offset" in params && params.offset !== undefined) {
303 | if (params.offset <= 1) delete params.offset;
304 | }
305 | if ("dateformats" in params && params.dateformats !== undefined) {
306 | // reassign dateformats to match FileMaker's expected values
307 | // @ts-expect-error FM wants a string, so this is fine
308 | params.dateformats = (
309 | params.dateformats === "US"
310 | ? 0
311 | : params.dateformats === "file_locale"
312 | ? 1
313 | : params.dateformats === "ISO8601"
314 | ? 2
315 | : 0
316 | ).toString();
317 | }
318 | const data = (await find({
319 | data: { ...params, query },
320 | layout,
321 | fetch,
322 | timeout,
323 | }).catch((e: unknown) => {
324 | if (ignoreEmptyResult && e instanceof FileMakerError && e.code === "401")
325 | return { data: [], dataInfo: { foundCount: 0, returnedCount: 0 } };
326 | throw e;
327 | })) as GetResponse;
328 |
329 | if (data.dataInfo.foundCount > data.dataInfo.returnedCount) {
330 | // more records found than returned
331 | if (args?.limit === undefined && args?.offset === undefined) {
332 | console.warn(
333 | `🚨 @proofgeistfmdapi: Loaded only ${data.dataInfo.returnedCount} of the ${data.dataInfo.foundCount} records from your "${layout}" layout. Use the "findAll" method to automatically paginate through all records, or specify a "limit" and "offset" to handle pagination yourself.`,
334 | );
335 | }
336 | }
337 |
338 | if (zodTypes) {
339 | if (data.data.length !== 0 || !ignoreEmptyResult) {
340 | // Parse if we have data or if ignoreEmptyResult is false
341 | ZGetResponse(zodTypes).parse(data);
342 | }
343 | }
344 | return data;
345 | }
346 |
347 | /**
348 | * Helper method for `find`. Will only return the first result or throw error if there is more than 1 result.
349 | */
350 | async function findOne(
351 | args: Opts["layout"] extends string
352 | ? FindArgs & Partial & FetchOptions
353 | : FindArgs & WithLayout & FetchOptions,
354 | ): Promise> {
355 | const res = await _find(args);
356 | if (res.data.length !== 1)
357 | throw new Error(`${res.data.length} records found; expecting exactly 1`);
358 | if (zodTypes) ZGetResponse(zodTypes).parse(res);
359 | if (!res.data[0]) throw new Error("No data found");
360 | return { ...res, data: res.data[0] };
361 | }
362 |
363 | /**
364 | * Helper method for `find`. Will only return the first result instead of an array.
365 | */
366 | async function findFirst(
367 | args: Opts["layout"] extends string
368 | ? FindArgs & IgnoreEmptyResult & Partial & FetchOptions
369 | : FindArgs & IgnoreEmptyResult & WithLayout & FetchOptions,
370 | ): Promise> {
371 | const res = await _find(args);
372 | if (zodTypes) ZGetResponse(zodTypes).parse(res);
373 | if (!res.data[0]) throw new Error("No data found");
374 | return { ...res, data: res.data[0] };
375 | }
376 |
377 | /**
378 | * Helper method for `find`. Will return the first result or null if no results are found.
379 | */
380 | async function maybeFindFirst(
381 | args: Opts["layout"] extends string
382 | ? FindArgs & IgnoreEmptyResult & Partial & FetchOptions
383 | : FindArgs & IgnoreEmptyResult & WithLayout & FetchOptions,
384 | ): Promise | null> {
385 | const res = await _find({ ...args, ignoreEmptyResult: true });
386 | if (zodTypes) ZGetResponse(zodTypes).parse(res);
387 | if (!res.data[0]) return null;
388 | return { ...res, data: res.data[0] };
389 | }
390 |
391 | /**
392 | * Helper method for `find` to page through all found results.
393 | * ⚠️ WARNING: Use with caution as this can be a slow operation with large datasets
394 | */
395 | async function findAll(
396 | args: Opts["layout"] extends string
397 | ? FindArgs & Partial & FetchOptions
398 | : FindArgs & WithLayout & FetchOptions,
399 | ): Promise[]> {
400 | let runningData: GetResponse["data"] = [];
401 | const limit = args.limit ?? 100;
402 | let offset = args.offset ?? 1;
403 | // eslint-disable-next-line no-constant-condition
404 | while (true) {
405 | const data = await _find({
406 | ...args,
407 | offset,
408 | ignoreEmptyResult: true,
409 | });
410 | runningData = [...runningData, ...data.data];
411 | if (
412 | runningData.length === 0 ||
413 | runningData.length >= data.dataInfo.foundCount
414 | )
415 | break;
416 | offset = offset + limit;
417 | }
418 | return runningData;
419 | }
420 |
421 | async function _layoutMetadata(
422 | args?: Opts["layout"] extends string
423 | ? { timeout?: number } & Partial & FetchOptions
424 | : { timeout?: number } & WithLayout & FetchOptions,
425 | ) {
426 | const { layout = options.layout, ...restArgs } = args ?? {};
427 | // Explicitly define the type for params based on FetchOptions
428 | const params: FetchOptions & { timeout?: number } = restArgs;
429 |
430 | if (layout === undefined) throw new Error("Layout is required");
431 | return await layoutMetadata({
432 | layout,
433 | fetch: params.fetch, // Now should correctly resolve to undefined if not present
434 | timeout: params.timeout, // Now should correctly resolve to undefined if not present
435 | });
436 | }
437 |
438 | async function _containerUpload(
439 | args: Opts["layout"] extends string
440 | ? ContainerUploadArgs & Partial & FetchOptions
441 | : ContainerUploadArgs & WithLayout & FetchOptions,
442 | ) {
443 | const { layout = options.layout, ...params } = args;
444 | if (layout === undefined) throw new Error("Layout is required");
445 | return await containerUpload({
446 | layout,
447 | data: {
448 | ...params,
449 | containerFieldName: params.containerFieldName as string,
450 | repetition: params.containerFieldRepetition,
451 | },
452 | fetch: params.fetch,
453 | timeout: params.timeout,
454 | });
455 | }
456 |
457 | return {
458 | ...otherMethods,
459 | layout: options.layout as Opts["layout"],
460 | list: _list,
461 | listAll,
462 | create: _create,
463 | get: _get,
464 | update: _update,
465 | delete: deleteRecord,
466 | find: _find,
467 | findOne,
468 | findFirst,
469 | maybeFindFirst,
470 | findAll,
471 | layoutMetadata: _layoutMetadata,
472 | containerUpload: _containerUpload,
473 | };
474 | }
475 |
476 | export default DataApi;
477 | export { DataApi };
478 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import { FileMakerError } from "./client-types.js";
2 | import { DataApi } from "./client.js";
3 |
4 | export { DataApi, FileMakerError };
5 | export * from "./utils/utils.js";
6 |
7 | export { FetchAdapter } from "./adapters/fetch.js";
8 | export { OttoAdapter, type OttoAPIKey } from "./adapters/otto.js";
9 |
10 | export default DataApi;
11 |
--------------------------------------------------------------------------------
/src/tokenStore/file.ts:
--------------------------------------------------------------------------------
1 | import type { TokenStoreDefinitions } from "./types.js";
2 | import fs from "fs-extra";
3 |
4 | function getDataFromFile(devFileName: string): Record {
5 | const data: Record = {};
6 | fs.ensureFileSync(devFileName);
7 | const fileString = fs.readFileSync(devFileName, "utf8");
8 | try {
9 | return JSON.parse(fileString);
10 | } catch {
11 | return data;
12 | }
13 | }
14 | const setSharedData = (key: string, value: string, devFileName: string) => {
15 | const data = getDataFromFile(devFileName);
16 | data[key] = value;
17 | fs.ensureFileSync(devFileName);
18 | fs.writeFileSync(devFileName, JSON.stringify(data, null, 2));
19 | };
20 | const getSharedData = (key: string, devFileName: string): string | null => {
21 | const data = getDataFromFile(devFileName);
22 | return data[key] ?? null;
23 | };
24 | export const fileTokenStore = (
25 | fileName = "shared.json",
26 | ): TokenStoreDefinitions => {
27 | return {
28 | setToken: (key, value) => setSharedData(key, value, fileName),
29 | getToken: (key) => getSharedData(key, fileName),
30 | clearToken: () => fs.removeSync(fileName),
31 | };
32 | };
33 | export default fileTokenStore;
34 |
--------------------------------------------------------------------------------
/src/tokenStore/index.ts:
--------------------------------------------------------------------------------
1 | export { default as upstashTokenStore } from "./upstash.js";
2 | export { default as fileStore } from "./file.js";
3 | export { default as memoryStore } from "./memory.js";
4 |
--------------------------------------------------------------------------------
/src/tokenStore/memory.ts:
--------------------------------------------------------------------------------
1 | import type { TokenStoreDefinitions } from "./types.js";
2 |
3 | export function memoryStore(): TokenStoreDefinitions {
4 | const data: Record = {};
5 | return {
6 | getToken: (key: string): string | null => {
7 | try {
8 | return data[key] ?? null;
9 | } catch {
10 | return null;
11 | }
12 | },
13 | clearToken: (key: string) => delete data[key],
14 | setToken: (key: string, value: string): void => {
15 | data[key] = value;
16 | },
17 | };
18 | }
19 |
20 | export default memoryStore;
21 |
--------------------------------------------------------------------------------
/src/tokenStore/types.ts:
--------------------------------------------------------------------------------
1 | type MaybePromise = Promise | T;
2 | export type TokenStoreDefinitions = {
3 | getKey?: () => string;
4 | getToken: (key: string) => MaybePromise;
5 | setToken: (key: string, value: string) => void;
6 | clearToken: (key: string) => void;
7 | };
8 |
--------------------------------------------------------------------------------
/src/tokenStore/upstash.ts:
--------------------------------------------------------------------------------
1 | import type { TokenStoreDefinitions } from "./types.js";
2 | import type { RedisConfigNodejs } from "@upstash/redis";
3 |
4 | export function upstashTokenStore(
5 | config: RedisConfigNodejs,
6 | options: { prefix?: string } = {},
7 | ): TokenStoreDefinitions {
8 | const { prefix = "" } = options;
9 |
10 | const getRedis = async () => {
11 | const redis = await import("@upstash/redis");
12 | return new redis.Redis(config);
13 | };
14 |
15 | return {
16 | getToken: async (key: string) => {
17 | const redis = await getRedis();
18 | return redis.get(prefix + key);
19 | },
20 | setToken: async (key: string, value: string) => {
21 | const redis = await getRedis();
22 | await redis.set(prefix + key, value);
23 | },
24 | clearToken: async (key: string) => {
25 | const redis = await getRedis();
26 | await redis.del(prefix + key);
27 | },
28 | };
29 | }
30 |
31 | export default upstashTokenStore;
32 |
--------------------------------------------------------------------------------
/src/utils/codegen.ts:
--------------------------------------------------------------------------------
1 | import { generateTypedClients } from "./typegen/index.js";
2 | import { getLayoutMetadata } from "./typegen/getLayoutMetadata.js";
3 | export type {
4 | ValueListsOptions,
5 | GenerateSchemaOptions,
6 | } from "./typegen/types.js";
7 |
8 | /**
9 | * @deprecated Use `getLayoutMetadata` from `@proofgeist/fmdapi/typegen` instead.
10 | */
11 | export const getSchema: typeof getLayoutMetadata = async (...args) => {
12 | return await getLayoutMetadata(...args);
13 | };
14 |
15 | /**
16 | * @deprecated Use `generateTypedClients` from `@proofgeist/fmdapi/typegen` instead.
17 | */
18 | export const generateSchemas: typeof generateTypedClients = async (...args) => {
19 | generateTypedClients(...args);
20 | return;
21 | };
22 |
--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | export { removeFMTableNames } from "./utils.js";
2 | export { generateTypedClients } from "./typegen/index.js";
3 | export { generateSchemas } from "./codegen.js";
4 |
--------------------------------------------------------------------------------
/src/utils/typegen/buildLayoutClient.ts:
--------------------------------------------------------------------------------
1 | import { CodeBlockWriter, SourceFile, VariableDeclarationKind } from "ts-morph";
2 | import { type BuildSchemaArgs } from "./types.js";
3 | import { isOttoAuth } from "../../adapters/otto.js";
4 |
5 | export function buildLayoutClient(
6 | sourceFile: SourceFile,
7 | args: BuildSchemaArgs,
8 | ) {
9 | const {
10 | schemaName,
11 | portalSchema,
12 | envNames,
13 | type,
14 | webviewerScriptName,
15 | layoutName,
16 | } = args;
17 | const fmdapiImport = sourceFile.addImportDeclaration({
18 | moduleSpecifier: "@proofgeist/fmdapi",
19 | namedImports: ["DataApi"],
20 | });
21 | const hasPortals = (portalSchema ?? []).length > 0;
22 | if (webviewerScriptName) {
23 | sourceFile.addImportDeclaration({
24 | moduleSpecifier: `@proofgeist/fm-webviewer-fetch/adapter`,
25 | namedImports: ["WebViewerAdapter"],
26 | });
27 | } else if (isOttoAuth(envNames.auth)) {
28 | // if otto, add the OttoAdapter and OttoAPIKey imports
29 | fmdapiImport.addNamedImports([
30 | { name: "OttoAdapter" },
31 | { name: "OttoAPIKey", isTypeOnly: true },
32 | ]);
33 | } else {
34 | fmdapiImport.addNamedImport({ name: "FetchAdapter" });
35 | }
36 |
37 | // import the types
38 | const schemaImport = sourceFile.addImportDeclaration({
39 | moduleSpecifier: `../${schemaName}`,
40 | namedImports: [{ name: `T${schemaName}`, isTypeOnly: true }],
41 | });
42 | if (type === "zod") schemaImport.addNamedImport(`Z${schemaName}`);
43 |
44 | // add portal imports
45 | if (hasPortals) {
46 | schemaImport.addNamedImport({
47 | name: `T${schemaName}Portals`,
48 | isTypeOnly: true,
49 | });
50 | if (type === "zod") schemaImport.addNamedImport(`Z${schemaName}Portals`);
51 | }
52 |
53 | if (!webviewerScriptName) {
54 | addTypeGuardStatements(sourceFile, { envVarName: envNames.db });
55 | addTypeGuardStatements(sourceFile, { envVarName: envNames.server });
56 | if (isOttoAuth(envNames.auth)) {
57 | addTypeGuardStatements(sourceFile, { envVarName: envNames.auth.apiKey });
58 | } else {
59 | addTypeGuardStatements(sourceFile, {
60 | envVarName: envNames.auth.username,
61 | });
62 | addTypeGuardStatements(sourceFile, {
63 | envVarName: envNames.auth.password,
64 | });
65 | }
66 | }
67 |
68 | sourceFile.addVariableStatement({
69 | declarationKind: VariableDeclarationKind.Const,
70 | isExported: true,
71 | declarations: [
72 | {
73 | name: "client",
74 | initializer: (writer) => {
75 | writer
76 | .write(
77 | `DataApi(`,
80 | )
81 | .inlineBlock(() => {
82 | writer.write(`adapter: `);
83 | buildAdapter(writer, args);
84 | writer.write(",").newLine();
85 | writer.write(`layout: `).quote(layoutName).write(`,`).newLine();
86 | if (type === "zod") {
87 | writer.writeLine(
88 | `zodValidators: { fieldData: Z${schemaName}${
89 | hasPortals ? `, portalData: Z${schemaName}Portals` : ""
90 | } },`,
91 | );
92 | }
93 | })
94 | .write(")");
95 | },
96 | },
97 | ],
98 | });
99 |
100 | // sourceFile.addExportAssignment({ isExportEquals: true, expression: "" });
101 | }
102 |
103 | function addTypeGuardStatements(
104 | sourceFile: SourceFile,
105 | { envVarName }: { envVarName: string },
106 | ) {
107 | sourceFile.addStatements((writer) => {
108 | writer.writeLine(
109 | `if (!process.env.${envVarName}) throw new Error("Missing env var: ${envVarName}")`,
110 | );
111 | });
112 | }
113 |
114 | function buildAdapter(writer: CodeBlockWriter, args: BuildSchemaArgs): string {
115 | const { envNames, webviewerScriptName } = args;
116 |
117 | if (webviewerScriptName) {
118 | writer.write(`new WebViewerAdapter({scriptName: `);
119 | writer.quote(webviewerScriptName);
120 | writer.write("})");
121 | } else if (isOttoAuth(envNames.auth)) {
122 | writer
123 | .write(`new OttoAdapter(`)
124 | .inlineBlock(() => {
125 | if (!isOttoAuth(envNames.auth)) return;
126 | writer
127 | .write(
128 | `auth: { apiKey: process.env.${envNames.auth.apiKey} as OttoAPIKey }`,
129 | )
130 | .write(",")
131 | .newLine();
132 | writer.write(`db: process.env.${envNames.db}`).write(",").newLine();
133 | writer
134 | .write(`server: process.env.${envNames.server}`)
135 | .write(",")
136 | .newLine();
137 | })
138 | .write(`)`);
139 | } else {
140 | writer
141 | .write(`new FetchAdapter(`)
142 | .inlineBlock(() => {
143 | if (isOttoAuth(envNames.auth)) return;
144 | writer
145 | .writeLine(`auth:`)
146 | .inlineBlock(() => {
147 | if (isOttoAuth(envNames.auth)) return;
148 | writer
149 | .write(`username: process.env.${envNames.auth.username}`)
150 | .write(",")
151 | .newLine();
152 | writer.write(`password: process.env.${envNames.auth.password}`);
153 | })
154 | .write(",")
155 | .writeLine(`db: process.env.${envNames.db},`)
156 | .writeLine(`server: process.env.${envNames.server}`);
157 | })
158 | .write(")");
159 | }
160 |
161 | return writer.toString();
162 | }
163 |
--------------------------------------------------------------------------------
/src/utils/typegen/buildSchema.ts:
--------------------------------------------------------------------------------
1 | import { VariableDeclarationKind, type SourceFile } from "ts-morph";
2 | import type { BuildSchemaArgs, TSchema } from "./types.js";
3 | import { varname } from "./constants.js";
4 |
5 | export function buildSchema(
6 | schemaFile: SourceFile,
7 | { type, ...args }: BuildSchemaArgs,
8 | ): void {
9 | // make sure schema has unique keys, in case a field is on the layout mulitple times
10 | args.schema.reduce(
11 | (acc: TSchema[], el) =>
12 | acc.find((o) => o.name === el.name)
13 | ? acc
14 | : ([...acc, el] as Array),
15 | [],
16 | );
17 |
18 | // setup
19 | const {
20 | schema,
21 | schemaName,
22 | portalSchema = [],
23 | valueLists = [],
24 | strictNumbers = false,
25 | } = args;
26 |
27 | if (type === "zod") {
28 | schemaFile.addImportDeclaration({
29 | moduleSpecifier: "zod",
30 | namedImports: ["z"],
31 | });
32 | }
33 |
34 | // build the portals
35 | for (const p of portalSchema) {
36 | type === "ts"
37 | ? buildTypeTS(schemaFile, {
38 | schemaName: p.schemaName,
39 | schema: p.schema,
40 | strictNumbers,
41 | })
42 | : buildTypeZod(schemaFile, {
43 | schemaName: p.schemaName,
44 | schema: p.schema,
45 | strictNumbers,
46 | });
47 | }
48 |
49 | // build the value lists
50 | for (const vls of valueLists) {
51 | if (vls.values.length > 0) {
52 | type === "ts"
53 | ? buildValueListTS(schemaFile, {
54 | name: vls.name,
55 | values: vls.values,
56 | })
57 | : buildValueListZod(schemaFile, {
58 | name: vls.name,
59 | values: vls.values,
60 | });
61 | }
62 | }
63 |
64 | // build the main schema
65 | type === "ts"
66 | ? buildTypeTS(schemaFile, {
67 | schemaName,
68 | schema,
69 | strictNumbers,
70 | })
71 | : buildTypeZod(schemaFile, {
72 | schemaName,
73 | schema,
74 | strictNumbers,
75 | });
76 |
77 | // build the final portals object
78 | if (portalSchema.length > 0) {
79 | if (type === "ts") {
80 | schemaFile.addTypeAlias({
81 | name: `T${varname(schemaName)}Portals`,
82 | type: (writer) => {
83 | writer.block(() => {
84 | portalSchema.forEach((p) => {
85 | writer.write(`${p.schemaName}: T${varname(p.schemaName)}`);
86 | });
87 | });
88 | },
89 | isExported: true,
90 | });
91 | } else {
92 | schemaFile.addVariableStatement({
93 | isExported: true,
94 | declarationKind: VariableDeclarationKind.Const,
95 | declarations: [
96 | {
97 | name: `Z${varname(schemaName)}Portals`,
98 | initializer: (writer) => {
99 | writer
100 | .write(`z.object(`)
101 | .inlineBlock(() => {
102 | portalSchema.map((p, i) => {
103 | writer
104 | .quote(p.schemaName)
105 | .write(": ")
106 | .write(`Z${varname(p.schemaName)}`);
107 | writer.conditionalWrite(i !== portalSchema.length - 1, ",");
108 | });
109 | })
110 | .write(")");
111 | },
112 | },
113 | ],
114 | });
115 | schemaFile.addTypeAlias({
116 | name: `T${varname(schemaName)}Portals`,
117 | type: `z.infer`,
118 | isExported: true,
119 | });
120 | }
121 | }
122 | }
123 |
124 | function buildTypeTS(
125 | schemaFile: SourceFile,
126 | {
127 | schemaName,
128 | schema,
129 | strictNumbers = false,
130 | }: {
131 | schemaName: string;
132 | schema: Array;
133 | strictNumbers?: boolean;
134 | },
135 | ) {
136 | schemaFile.addTypeAlias({
137 | name: `T${varname(schemaName)}`,
138 | type: (writer) => {
139 | writer.inlineBlock(() => {
140 | schema.forEach((field) => {
141 | writer.quote(field.name).write(": ");
142 | if (field.type === "string") {
143 | writer.write("string");
144 | } else if (field.type === "fmnumber") {
145 | if (strictNumbers) {
146 | writer.write("number | null");
147 | } else {
148 | writer.write("string | number");
149 | }
150 | } else if (field.type === "valueList") {
151 | writer.write(`"${field.values?.join('" | "')}"`);
152 | } else {
153 | writer.write("any");
154 | }
155 | writer.write(",").newLine();
156 | });
157 | });
158 | },
159 | isExported: true,
160 | });
161 | }
162 |
163 | function buildValueListTS(
164 | schemaFile: SourceFile,
165 | {
166 | name,
167 | values,
168 | }: {
169 | name: string;
170 | values: Array;
171 | },
172 | ) {
173 | schemaFile.addTypeAlias({
174 | name: `TVL${varname(name)}`,
175 | type: `"${values.join('" | "')}"`,
176 | isExported: true,
177 | });
178 | }
179 |
180 | function buildTypeZod(
181 | schemaFile: SourceFile,
182 | {
183 | schemaName,
184 | schema,
185 | strictNumbers = false,
186 | }: {
187 | schemaName: string;
188 | schema: Array;
189 | strictNumbers?: boolean;
190 | },
191 | ) {
192 | schemaFile.addVariableStatement({
193 | isExported: true,
194 | declarationKind: VariableDeclarationKind.Const,
195 | declarations: [
196 | {
197 | name: `Z${varname(schemaName)}`,
198 | initializer: (writer) => {
199 | writer
200 | .write(`z.object(`)
201 | .inlineBlock(() => {
202 | schema.forEach((field) => {
203 | writer.quote(field.name).write(": ");
204 | if (field.type === "string") {
205 | writer.write("z.string()");
206 | } else if (field.type === "fmnumber") {
207 | if (strictNumbers) {
208 | writer.write("z.number().nullable()");
209 | } else {
210 | writer.write("z.union([z.string(), z.number()])");
211 | }
212 | } else if (field.type === "valueList") {
213 | writer.write(`z.enum([`);
214 | field.values?.map((v, i) =>
215 | writer
216 | .quote(v)
217 | .conditionalWrite(
218 | i !== (field.values ?? []).length - 1,
219 | ", ",
220 | ),
221 | );
222 | writer.write("])");
223 | writer.conditionalWrite(
224 | field.values?.includes(""),
225 | `.catch("")`,
226 | );
227 | } else {
228 | writer.write("z.any()");
229 | }
230 | writer.write(",").newLine();
231 | });
232 | })
233 | .write(")");
234 | },
235 | },
236 | ],
237 | });
238 | schemaFile.addTypeAlias({
239 | name: `T${varname(schemaName)}`,
240 | type: `z.infer`,
241 | isExported: true,
242 | });
243 | }
244 |
245 | function buildValueListZod(
246 | schemaFile: SourceFile,
247 | {
248 | name,
249 | values,
250 | }: {
251 | name: string;
252 | values: Array;
253 | },
254 | ) {
255 | schemaFile.addVariableStatement({
256 | isExported: true,
257 | declarationKind: VariableDeclarationKind.Const,
258 | declarations: [
259 | {
260 | name: `ZVL${varname(name)}`,
261 | initializer: (writer) => {
262 | writer.write(`z.enum([`);
263 | values.map((v, i) =>
264 | writer.quote(v).conditionalWrite(i !== values.length - 1, ", "),
265 | );
266 | writer.write("])");
267 | },
268 | },
269 | ],
270 | });
271 | schemaFile.addTypeAlias({
272 | name: `TVL${varname(name)}`,
273 | type: `z.infer`,
274 | isExported: true,
275 | });
276 | }
277 |
--------------------------------------------------------------------------------
/src/utils/typegen/constants.ts:
--------------------------------------------------------------------------------
1 | export const commentHeader = `
2 | /**
3 | * Generated by @proofgeist/fmdapi package
4 | * https://github.com/proofgeist/fmdapi
5 | * DO NOT EDIT THIS FILE DIRECTLY. Changes may be overritten
6 | */
7 |
8 | // @generated
9 | // prettier-ignore
10 | /* eslint-disable */
11 |
12 | `;
13 |
14 | export const varname = (name: string) => name.replace(/[^a-zA-Z0-9_]/g, "");
15 |
--------------------------------------------------------------------------------
/src/utils/typegen/getLayoutMetadata.ts:
--------------------------------------------------------------------------------
1 | import type { DataApi } from "../../client.js";
2 | import type { TSchema, ValueListsOptions } from "./types.js";
3 | import type { F } from "ts-toolbelt";
4 | import chalk from "chalk";
5 | import { type FieldMetaData, FileMakerError } from "../../client-types.js";
6 |
7 | /**
8 | * Calls the FileMaker Data API to get the layout metadata and returns a schema
9 | */
10 | export const getLayoutMetadata = async (args: {
11 | client: ReturnType;
12 | layout: string;
13 | valueLists?: ValueListsOptions;
14 | }) => {
15 | const schemaReducer: F.Function<[FieldMetaData[]], TSchema[]> = (schema) =>
16 | schema.reduce((acc, field) => {
17 | if (acc.find((o) => o.name === field.name)) return acc; // skip duplicates
18 | if (
19 | meta &&
20 | field.valueList &&
21 | meta.valueLists &&
22 | valueLists !== "ignore"
23 | ) {
24 | const list = meta.valueLists.find((o) => o.name === field.valueList);
25 | const values = list?.values.map((o) => o.value) ?? [];
26 | return [
27 | ...acc,
28 | {
29 | name: field.name,
30 | type: "valueList",
31 | values: valueLists === "allowEmpty" ? [...values, ""] : values,
32 | },
33 | ];
34 | }
35 | return [
36 | ...acc,
37 | {
38 | name: field.name,
39 | type: field.result === "number" ? "fmnumber" : "string",
40 | },
41 | ];
42 | }, [] as TSchema[]);
43 |
44 | const { client, layout, valueLists = "ignore" } = args;
45 | const meta = await client.layoutMetadata({ layout }).catch((err) => {
46 | if (err instanceof FileMakerError && err.code === "105") {
47 | console.log(
48 | chalk.bold.red("ERROR:"),
49 | "Skipping schema generation for layout:",
50 | chalk.bold.underline(layout),
51 | "(not found)",
52 | );
53 | return;
54 | }
55 | throw err;
56 | });
57 | if (!meta) return;
58 | const schema = schemaReducer(meta.fieldMetaData);
59 | const portalSchema = Object.keys(meta.portalMetaData).map((schemaName) => {
60 | const schema = schemaReducer(meta.portalMetaData[schemaName] ?? []);
61 | return { schemaName, schema };
62 | });
63 | const valueListValues =
64 | meta.valueLists?.map((vl) => ({
65 | name: vl.name,
66 | values: vl.values.map((o) => o.value),
67 | })) ?? [];
68 | // remove duplicates from valueListValues
69 | const valueListValuesUnique = valueListValues.reduce(
70 | (acc, vl) => {
71 | if (acc.find((o) => o.name === vl.name)) return acc;
72 | return [...acc, vl];
73 | },
74 | [] as typeof valueListValues,
75 | );
76 |
77 | return { schema, portalSchema, valueLists: valueListValuesUnique };
78 | };
79 |
--------------------------------------------------------------------------------
/src/utils/typegen/index.ts:
--------------------------------------------------------------------------------
1 | import { Project, ScriptKind } from "ts-morph";
2 | import {
3 | type BuildSchemaArgs,
4 | type ClientObjectProps,
5 | type GenerateSchemaOptions,
6 | type GenerateSchemaOptionsSingle,
7 | } from "./types.js";
8 | import chalk from "chalk";
9 | import {
10 | isOttoAuth,
11 | OttoAdapter,
12 | type OttoAPIKey,
13 | } from "../../adapters/otto.js";
14 | import DataApi from "../../client.js";
15 | import { FetchAdapter } from "../../adapters/fetch.js";
16 | import { memoryStore } from "../../tokenStore/index.js";
17 | import fs from "fs-extra";
18 | import path from "path";
19 | import { commentHeader } from "./constants.js";
20 | import { buildSchema } from "./buildSchema.js";
21 | import { getLayoutMetadata } from "./getLayoutMetadata.js";
22 | import { buildLayoutClient } from "./buildLayoutClient.js";
23 |
24 | export const generateTypedClients = async (options: GenerateSchemaOptions) => {
25 | if (Array.isArray(options)) {
26 | for (const option of options) {
27 | await generateTypedClientsSingle(option);
28 | }
29 | } else {
30 | await generateTypedClientsSingle(options);
31 | }
32 | };
33 |
34 | const generateTypedClientsSingle = async (
35 | options: GenerateSchemaOptionsSingle,
36 | ) => {
37 | const {
38 | envNames,
39 | schemas,
40 | clientSuffix = "Client",
41 | useZod = true,
42 | generateClient = true,
43 | clearOldFiles = false,
44 | ...rest
45 | } = options;
46 |
47 | const rootDir = rest.path ?? "schema";
48 |
49 | const defaultEnvNames = {
50 | apiKey: "OTTO_API_KEY",
51 | ottoPort: "OTTO_PORT",
52 | username: "FM_USERNAME",
53 | password: "FM_PASSWORD",
54 | server: "FM_SERVER",
55 | db: "FM_DATABASE",
56 | };
57 |
58 | const project = new Project({});
59 |
60 | if (options.tokenStore)
61 | console.log(
62 | `${chalk.yellow(
63 | "NOTE:",
64 | )} The tokenStore option is deprecated and will NOT be included in the generated client.`,
65 | );
66 |
67 | const server = process.env[envNames?.server ?? defaultEnvNames.server];
68 | const db = process.env[envNames?.db ?? defaultEnvNames.db];
69 | const apiKey =
70 | (envNames?.auth && isOttoAuth(envNames.auth)
71 | ? process.env[envNames.auth.apiKey ?? defaultEnvNames.apiKey]
72 | : undefined) ?? process.env[defaultEnvNames.apiKey];
73 | const username =
74 | (envNames?.auth && !isOttoAuth(envNames.auth)
75 | ? process.env[envNames.auth.username ?? defaultEnvNames.username]
76 | : undefined) ?? process.env[defaultEnvNames.username];
77 | const password =
78 | (envNames?.auth && !isOttoAuth(envNames.auth)
79 | ? process.env[envNames.auth.password ?? defaultEnvNames.password]
80 | : undefined) ?? process.env[defaultEnvNames.password];
81 |
82 | const auth: ClientObjectProps["auth"] = apiKey
83 | ? { apiKey: apiKey as OttoAPIKey }
84 | : { username: username ?? "", password: password ?? "" };
85 |
86 | if (!server || !db || (!apiKey && !username)) {
87 | console.log(chalk.red("ERROR: Could not get all required config values"));
88 | console.log("Ensure the following environment variables are set:");
89 | if (!server) console.log(`${envNames?.server ?? defaultEnvNames.server}`);
90 | if (!db) console.log(`${envNames?.db ?? defaultEnvNames.db}`);
91 | if (!apiKey)
92 | console.log(
93 | `${
94 | (envNames?.auth &&
95 | isOttoAuth(envNames.auth) &&
96 | envNames.auth.apiKey) ??
97 | defaultEnvNames.apiKey
98 | } (or ${
99 | (envNames?.auth &&
100 | !isOttoAuth(envNames.auth) &&
101 | envNames.auth.username) ??
102 | defaultEnvNames.username
103 | } and ${
104 | (envNames?.auth &&
105 | !isOttoAuth(envNames.auth) &&
106 | envNames.auth.password) ??
107 | defaultEnvNames.password
108 | })`,
109 | );
110 |
111 | console.log();
112 | return;
113 | }
114 |
115 | const client = isOttoAuth(auth)
116 | ? DataApi({
117 | adapter: new OttoAdapter({ auth, server, db }),
118 | })
119 | : DataApi({
120 | adapter: new FetchAdapter({
121 | auth,
122 | server,
123 | db,
124 | tokenStore: memoryStore(),
125 | }),
126 | });
127 | await fs.ensureDir(rootDir);
128 | if (clearOldFiles) {
129 | fs.emptyDirSync(rootDir);
130 | }
131 | const clientIndexFilePath = path.join(rootDir, "client", "index.ts");
132 | fs.rmSync(clientIndexFilePath, { force: true }); // ensure clean slate for this file
133 |
134 | for await (const item of schemas) {
135 | const result = await getLayoutMetadata({
136 | client,
137 | layout: item.layout,
138 | valueLists: item.valueLists,
139 | });
140 | if (!result) continue;
141 |
142 | const { schema, portalSchema, valueLists } = result;
143 | const args: BuildSchemaArgs = {
144 | schemaName: item.schemaName,
145 | schema,
146 | layoutName: item.layout,
147 | portalSchema,
148 | valueLists,
149 | type: useZod ? "zod" : "ts",
150 | strictNumbers: item.strictNumbers,
151 | webviewerScriptName: options.webviewerScriptName,
152 | envNames: {
153 | auth: isOttoAuth(auth)
154 | ? {
155 | apiKey:
156 | envNames?.auth && "apiKey" in envNames.auth
157 | ? envNames.auth.apiKey
158 | : (defaultEnvNames.apiKey as OttoAPIKey),
159 | }
160 | : {
161 | username:
162 | envNames?.auth && "username" in envNames.auth
163 | ? envNames.auth.username
164 | : defaultEnvNames.username,
165 | password:
166 | envNames?.auth && "password" in envNames.auth
167 | ? envNames.auth.password
168 | : defaultEnvNames.password,
169 | },
170 | db: envNames?.db ?? defaultEnvNames.db,
171 | server: envNames?.server ?? defaultEnvNames.server,
172 | },
173 | };
174 | const schemaFile = project.createSourceFile(
175 | path.join(rootDir, `${item.schemaName}.ts`),
176 | { leadingTrivia: commentHeader },
177 | {
178 | overwrite: true,
179 | scriptKind: ScriptKind.TS,
180 | },
181 | );
182 | buildSchema(schemaFile, args);
183 |
184 | if (item.generateClient ?? generateClient) {
185 | await fs.ensureDir(path.join(rootDir, "client"));
186 | const layoutClientFile = project.createSourceFile(
187 | path.join(rootDir, "client", `${item.schemaName}.ts`),
188 | { leadingTrivia: commentHeader },
189 | {
190 | overwrite: true,
191 | scriptKind: ScriptKind.TS,
192 | },
193 | );
194 | buildLayoutClient(layoutClientFile, args);
195 |
196 | await fs.ensureFile(clientIndexFilePath);
197 | const clientIndexFile = project.addSourceFileAtPath(clientIndexFilePath);
198 | clientIndexFile.addExportDeclaration({
199 | namedExports: [
200 | { name: "client", alias: `${item.schemaName}${clientSuffix}` },
201 | ],
202 | moduleSpecifier: `./${item.schemaName}`,
203 | });
204 | }
205 | }
206 |
207 | // format all files
208 | project.getSourceFiles().forEach((file) => {
209 | file.formatText({
210 | baseIndentSize: 2,
211 | });
212 | });
213 |
214 | await project.save();
215 | };
216 |
--------------------------------------------------------------------------------
/src/utils/typegen/types.ts:
--------------------------------------------------------------------------------
1 | import { type FetchAdapterOptions } from "../../adapters/fetch.js";
2 | import { type OttoAdapterOptions } from "../../adapters/otto.js";
3 | import { type TokenStoreDefinitions } from "../../tokenStore/types.js";
4 |
5 | export type ClientObjectProps = OttoAdapterOptions | FetchAdapterOptions;
6 |
7 | export type ValueListsOptions = "strict" | "allowEmpty" | "ignore";
8 |
9 | export type GenerateSchemaOptionsSingle = {
10 | envNames?: Partial>;
11 | schemas: Array<{
12 | layout: string;
13 | schemaName: string;
14 | valueLists?: ValueListsOptions;
15 | /**
16 | * If `true`, the generated files will include a layout-specific client. Set this to `false` if you only want to use the types. Overrides the top-level generateClient option for this specific schema.
17 | * @default true
18 | */
19 | generateClient?: boolean;
20 | /** If `true`, number fields will be typed as `number | null` instead of `number | string`. If the data cannot be parsed as a number, it will be set to `null`.
21 | * @default false
22 | */
23 | strictNumbers?: boolean;
24 | }>;
25 | /**
26 | * If `true`, the generated files will include a layout-specific client. Set this to `false` if you only want to use the types
27 | * @default true
28 | */
29 | generateClient?: boolean;
30 | /**
31 | * The path to the directory where the generated files will be saved.
32 | * @default `schema`
33 | */
34 | path?: string;
35 | /**
36 | * If `true`, the generated files will also generate a `Zod` schema and validate the data returned from FileMaker using the zod schemas to give you runtime checks for your data.
37 | * @default true
38 | */
39 | useZod?: boolean;
40 | /**
41 | * @deprecated This function was only relevant for the FetchAdapter and will not be included in your generated layout client anyway.
42 | */
43 | tokenStore?: () => TokenStoreDefinitions;
44 | /**
45 | * If set, the generated files will include the webviewer client instead of the standard REST API client.
46 | * This script should pass the parameter to the Execute Data API Script step and return the result to the webviewer per the "@proofgeist/fm-webviewer-fetch" documentation.
47 | * Requires "@proofgeist/fm-webviewer-fetch" installed as a peer dependency.
48 | * The REST API client (and related credentials) is still needed to generate the types.
49 | *
50 | * @link https://fm-webviewer-fetch.proofgeist.com/
51 | */
52 | webviewerScriptName?: string;
53 |
54 | /**
55 | * The suffix to add at the end of the generated layout-specific Data API client name.
56 | * @default `Client`
57 | */
58 | clientSuffix?: string;
59 | /**
60 | * If `true`, the directory specified in `path` will be cleared before generating the new files, ensuring that old schema files are removed from your project.
61 | * @default false
62 | */
63 | clearOldFiles?: boolean;
64 | };
65 |
66 | export type GenerateSchemaOptions =
67 | | GenerateSchemaOptionsSingle
68 | | GenerateSchemaOptionsSingle[];
69 |
70 | export type TSchema = {
71 | name: string;
72 | type: "string" | "fmnumber" | "valueList";
73 | values?: string[];
74 | };
75 |
76 | export type BuildSchemaArgs = {
77 | schemaName: string;
78 | schema: Array;
79 | type: "zod" | "ts";
80 | portalSchema?: { schemaName: string; schema: Array }[];
81 | valueLists?: { name: string; values: string[] }[];
82 | envNames: Omit;
83 | layoutName: string;
84 | strictNumbers?: boolean;
85 | webviewerScriptName?: string;
86 | } & Pick;
87 |
--------------------------------------------------------------------------------
/src/utils/utils.ts:
--------------------------------------------------------------------------------
1 | import type { S, L, U } from "ts-toolbelt";
2 |
3 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
4 | type TransformedFields> = U.Merge<
5 | {
6 | [Field in keyof T]: {
7 | [Key in Field extends string
8 | ? L.Last>
9 | : Field]: T[Field];
10 | };
11 | }[keyof T]
12 | >;
13 |
14 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
15 | export function removeFMTableNames>(
16 | obj: T,
17 | ): TransformedFields {
18 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
19 | const newObj: any = {};
20 | for (const key in obj) {
21 | if (key.includes("::")) {
22 | const newKey = key.split("::")[1];
23 | newObj[newKey as keyof TransformedFields] = obj[key];
24 | } else {
25 | newObj[key] = obj[key];
26 | }
27 | }
28 | return newObj;
29 | }
30 |
--------------------------------------------------------------------------------
/stubs/fmschema.config.stub.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @type {import("@proofgeist/fmdapi/dist/utils/typegen/types.d.ts").GenerateSchemaOptions}
3 | */
4 | const config = {
5 | clientSuffix: "Layout",
6 | schemas: [
7 | // add your layouts and name schemas here
8 | { layout: "my_layout", schemaName: "MySchema" },
9 |
10 | // repeat as needed for each schema...
11 | // { layout: "my_other_layout", schemaName: "MyOtherSchema" },
12 | ],
13 | // change this value to generate the files in a different directory
14 | path: "schema",
15 | clearOldFiles: true,
16 | };
17 | module.exports = config;
18 |
--------------------------------------------------------------------------------
/stubs/fmschema.config.stub.mjs:
--------------------------------------------------------------------------------
1 | /**
2 | * @type {import("@proofgeist/fmdapi/dist/utils/typegen/types.d.ts").GenerateSchemaOptions}
3 | */
4 | export const config = {
5 | clientSuffix: "Layout",
6 | schemas: [
7 | // add your layouts and name schemas here
8 | { layout: "my_layout", schemaName: "MySchema" },
9 |
10 | // repeat as needed for each schema...
11 | // { layout: "my_other_layout", schemaName: "MyOtherSchema" },
12 | ],
13 |
14 | // change this value to generate the files in a different directory
15 | path: "schema",
16 | clearOldFiles: true,
17 | };
18 |
--------------------------------------------------------------------------------
/test/client-methods.test.ts:
--------------------------------------------------------------------------------
1 | import { DataApi, OttoAdapter } from "../src";
2 | import {
3 | Layout,
4 | ScriptsMetadataResponse,
5 | ScriptOrFolder,
6 | AllLayoutsMetadataResponse,
7 | } from "../src/client-types";
8 | import {
9 | config,
10 | layoutClient,
11 | weirdPortalClient,
12 | containerClient,
13 | } from "./setup";
14 | import { describe, test, expect, it } from "vitest";
15 |
16 | describe("sort methods", () => {
17 | test("should sort descending", async () => {
18 | const resp = await layoutClient.list({
19 | sort: { fieldName: "recordId", sortOrder: "descend" },
20 | });
21 | expect(resp.data.length).toBe(3);
22 | const firstRecord = parseInt(resp.data[0].fieldData.recordId as string);
23 | const secondRecord = parseInt(resp.data[1].fieldData.recordId as string);
24 | expect(firstRecord).toBeGreaterThan(secondRecord);
25 | });
26 | test("should sort ascending by default", async () => {
27 | const resp = await layoutClient.list({
28 | sort: { fieldName: "recordId" },
29 | });
30 |
31 | const firstRecord = parseInt(resp.data[0].fieldData.recordId as string);
32 | const secondRecord = parseInt(resp.data[1].fieldData.recordId as string);
33 | expect(secondRecord).toBeGreaterThan(firstRecord);
34 | });
35 | });
36 |
37 | describe("find methods", () => {
38 | const client = DataApi({
39 | adapter: new OttoAdapter({
40 | auth: config.auth,
41 | db: config.db,
42 | server: config.server,
43 | }),
44 | });
45 |
46 | test("successful find", async () => {
47 | const resp = await client.find({
48 | layout: "layout",
49 | query: { anything: "anything" },
50 | });
51 | expect(Array.isArray(resp.data)).toBe(true);
52 | });
53 | test("successful findFirst with multiple return", async () => {
54 | const resp = await client.findFirst({
55 | layout: "layout",
56 | query: { anything: "anything" },
57 | });
58 | expect(Array.isArray(resp.data)).toBe(false);
59 | });
60 | test("successful findOne", async () => {
61 | const resp = await client.findOne({
62 | layout: "layout",
63 | query: { anything: "unique" },
64 | });
65 |
66 | expect(Array.isArray(resp.data)).toBe(false);
67 | });
68 | it("find with omit", async () => {
69 | await layoutClient.find<{ anything: string }>({
70 | query: { anything: "anything", omit: "true" },
71 | });
72 | });
73 | });
74 |
75 | describe("portal methods", () => {
76 | it("should return portal data with limit and offset", async () => {
77 | const result = await layoutClient.list({
78 | limit: 1,
79 | });
80 | expect(result.data[0].portalData.test.length).toBe(50); // default portal limit is 50
81 |
82 | const { data } = await layoutClient.list({
83 | limit: 1,
84 | portalRanges: { test: { limit: 1, offset: 2 } },
85 | });
86 | expect(data.length).toBe(1);
87 |
88 | const portalData = data[0].portalData;
89 | const testPortal = portalData.test;
90 | expect(testPortal.length).toBe(1);
91 | expect(testPortal[0]["related::related_field"]).toContain("2"); // we should get the 2nd record
92 | });
93 | it("should update portal data", async () => {
94 | await layoutClient.update({
95 | recordId: 1,
96 | fieldData: { anything: "anything" },
97 | portalData: {
98 | test: [{ "related::related_field": "updated", recordId: "1" }],
99 | },
100 | });
101 | });
102 | it("should handle portal methods with strange names", async () => {
103 | const { data } = await weirdPortalClient.list({
104 | limit: 1,
105 | portalRanges: {
106 | "long_and_strange.portalName#forTesting": { limit: 100 },
107 | },
108 | });
109 |
110 | expect(
111 | "long_and_strange.portalName#forTesting" in data[0].portalData,
112 | ).toBeTruthy();
113 |
114 | const portalData =
115 | data[0].portalData["long_and_strange.portalName#forTesting"];
116 |
117 | expect(portalData.length).toBeGreaterThan(50);
118 | });
119 | });
120 |
121 | describe("other methods", () => {
122 | it("should allow list method without layout param", async () => {
123 | const client = DataApi({
124 | adapter: new OttoAdapter({
125 | auth: config.auth,
126 | db: config.db,
127 | server: config.server,
128 | }),
129 | layout: "layout",
130 | });
131 |
132 | await client.list();
133 | });
134 | it("should require list method to have layout param", async () => {
135 | // if not passed into the top-level client
136 | const client = DataApi({
137 | adapter: new OttoAdapter({
138 | auth: config.auth,
139 | db: config.db,
140 | server: config.server,
141 | }),
142 | });
143 |
144 | await expect(client.list()).rejects.toThrow();
145 | });
146 |
147 | it("findOne with 2 results should fail", async () => {
148 | const client = DataApi({
149 | adapter: new OttoAdapter({
150 | auth: config.auth,
151 | db: config.db,
152 | server: config.server,
153 | }),
154 | });
155 |
156 | await expect(
157 | client.findOne({
158 | layout: "layout",
159 | query: { anything: "anything" },
160 | }),
161 | ).rejects.toThrow();
162 | });
163 |
164 | it("should rename offset param", async () => {
165 | const client = DataApi({
166 | adapter: new OttoAdapter({
167 | auth: config.auth,
168 | db: config.db,
169 | server: config.server,
170 | }),
171 | });
172 |
173 | await client.list({
174 | layout: "layout",
175 | offset: 0,
176 | });
177 | });
178 |
179 | it("should retrieve a list of folders and layouts", async () => {
180 | const client = DataApi({
181 | adapter: new OttoAdapter({
182 | auth: config.auth,
183 | db: config.db,
184 | server: config.server,
185 | }),
186 | });
187 |
188 | const resp = (await client.layouts()) as AllLayoutsMetadataResponse;
189 |
190 | expect(Object.prototype.hasOwnProperty.call(resp, "layouts")).toBe(true);
191 | expect(resp.layouts.length).toBeGreaterThanOrEqual(2);
192 | expect(resp.layouts[0] as Layout).toHaveProperty("name");
193 | const layoutFoler = resp.layouts.find((o) => "isFolder" in o);
194 | expect(layoutFoler).not.toBeUndefined();
195 | expect(layoutFoler).toHaveProperty("isFolder");
196 | expect(layoutFoler).toHaveProperty("folderLayoutNames");
197 | });
198 | it("should retrieve a list of folders and scripts", async () => {
199 | const client = DataApi({
200 | adapter: new OttoAdapter({
201 | auth: config.auth,
202 | db: config.db,
203 | server: config.server,
204 | }),
205 | });
206 |
207 | const resp = (await client.scripts()) as ScriptsMetadataResponse;
208 |
209 | expect(Object.prototype.hasOwnProperty.call(resp, "scripts")).toBe(true);
210 | expect(resp.scripts.length).toBe(2);
211 | expect(resp.scripts[0] as ScriptOrFolder).toHaveProperty("name");
212 | expect(resp.scripts[1] as ScriptOrFolder).toHaveProperty("isFolder");
213 | });
214 |
215 | it("should retrieve layout metadata with only the layout parameter", async () => {
216 | const client = DataApi({
217 | adapter: new OttoAdapter({
218 | auth: config.auth,
219 | db: config.db,
220 | server: config.server,
221 | }),
222 | });
223 | const layoutName = "layout"; // Assuming "layout" is a valid layout in the test DB
224 |
225 | // Call the method with only the required layout parameter
226 | const response = await client.layoutMetadata({ layout: layoutName });
227 |
228 | // Assertion 1: Ensure the call succeeded and returned a response object
229 | expect(response).toBeDefined();
230 | expect(response).toBeTypeOf("object");
231 |
232 | // Assertion 2: Check for the presence of core metadata properties
233 | expect(response).toHaveProperty("fieldMetaData");
234 | expect(response).toHaveProperty("portalMetaData");
235 | // valueLists is optional, check type if present
236 | if (response.valueLists) {
237 | expect(Array.isArray(response.valueLists)).toBe(true);
238 | }
239 |
240 | // Assertion 3: Verify the types of the core properties
241 | expect(Array.isArray(response.fieldMetaData)).toBe(true);
242 | expect(typeof response.portalMetaData).toBe("object");
243 |
244 | // Assertion 4 (Optional but recommended): Check structure of metadata
245 | if (response.fieldMetaData.length > 0) {
246 | expect(response.fieldMetaData[0]).toHaveProperty("name");
247 | expect(response.fieldMetaData[0]).toHaveProperty("type");
248 | }
249 | });
250 |
251 | it("should retrieve layout metadata when layout is configured on the client", async () => {
252 | const client = DataApi({
253 | adapter: new OttoAdapter({
254 | auth: config.auth,
255 | db: config.db,
256 | server: config.server,
257 | }),
258 | layout: "layout", // Configure layout on the client
259 | });
260 |
261 | // Call the method without the layout parameter (expecting it to use the client's layout)
262 | // No arguments should be needed when layout is configured on the client.
263 | const response = await client.layoutMetadata();
264 |
265 | // Assertion 1: Ensure the call succeeded and returned a response object
266 | expect(response).toBeDefined();
267 | expect(response).toBeTypeOf("object");
268 |
269 | // Assertion 2: Check for the presence of core metadata properties
270 | expect(response).toHaveProperty("fieldMetaData");
271 | expect(response).toHaveProperty("portalMetaData");
272 | // valueLists is optional, check type if present
273 | if (response.valueLists) {
274 | expect(Array.isArray(response.valueLists)).toBe(true);
275 | }
276 |
277 | // Assertion 3: Verify the types of the core properties
278 | expect(Array.isArray(response.fieldMetaData)).toBe(true);
279 | expect(typeof response.portalMetaData).toBe("object");
280 |
281 | // Assertion 4 (Optional but recommended): Check structure of metadata
282 | if (response.fieldMetaData.length > 0) {
283 | expect(response.fieldMetaData[0]).toHaveProperty("name");
284 | expect(response.fieldMetaData[0]).toHaveProperty("type");
285 | }
286 | });
287 |
288 | it("should paginate through all records", async () => {
289 | const client = DataApi({
290 | adapter: new OttoAdapter({
291 | auth: config.auth,
292 | db: config.db,
293 | server: config.server,
294 | }),
295 | layout: "layout",
296 | });
297 |
298 | const data = await client.listAll({ limit: 1 });
299 | expect(data.length).toBe(3);
300 | });
301 |
302 | it("should paginate using findAll method", async () => {
303 | const client = DataApi({
304 | adapter: new OttoAdapter({
305 | auth: config.auth,
306 | db: config.db,
307 | server: config.server,
308 | }),
309 | layout: "layout",
310 | });
311 |
312 | const data = await client.findAll({
313 | query: { anything: "anything" },
314 | limit: 1,
315 | });
316 | expect(data.length).toBe(2);
317 | });
318 |
319 | it("should return from execute script", async () => {
320 | const client = DataApi({
321 | adapter: new OttoAdapter({
322 | auth: config.auth,
323 | db: config.db,
324 | server: config.server,
325 | }),
326 | layout: "layout",
327 | });
328 |
329 | const param = JSON.stringify({ hello: "world" });
330 |
331 | const resp = await client.executeScript({
332 | script: "script",
333 | scriptParam: param,
334 | layout: client.layout,
335 | });
336 |
337 | expect(resp.scriptResult).toBe("result");
338 | });
339 | });
340 |
341 | describe("container field methods", () => {
342 | it("should upload a file to a container field", async () => {
343 | await containerClient.containerUpload({
344 | containerFieldName: "myContainer",
345 | file: new Blob([Buffer.from("test/fixtures/test.txt")]),
346 | recordId: "1",
347 | });
348 | });
349 |
350 | it("should handle container field repetition", async () => {
351 | await containerClient.containerUpload({
352 | containerFieldName: "repeatingContainer",
353 | containerFieldRepetition: 2,
354 | file: new Blob([Buffer.from("test/fixtures/test.txt")]),
355 | recordId: "1",
356 | });
357 | });
358 | });
359 |
--------------------------------------------------------------------------------
/test/fixtures/test.txt:
--------------------------------------------------------------------------------
1 | This is a test file for container field upload testing.
--------------------------------------------------------------------------------
/test/init-client.test.ts:
--------------------------------------------------------------------------------
1 | import { DataApi, FetchAdapter, FileMakerError, OttoAdapter } from "../src";
2 | import memoryStore from "../src/tokenStore/memory";
3 | import { client } from "./setup";
4 | import { describe, expect, test } from "vitest";
5 |
6 | describe("try to init client", () => {
7 | test("without server", () => {
8 | expect(() => {
9 | return DataApi({
10 | adapter: new OttoAdapter({
11 | auth: { apiKey: "dk_anything" },
12 | db: "anything",
13 | server: "",
14 | }),
15 | });
16 | }).toThrow();
17 | });
18 | test("without https", () => {
19 | expect(() =>
20 | DataApi({
21 | adapter: new OttoAdapter({
22 | auth: { apiKey: "dk_anything" },
23 | db: "anything",
24 | server: "http://example.com",
25 | }),
26 | }),
27 | ).not.toThrow();
28 | });
29 | test("without db", () => {
30 | expect(
31 | () =>
32 | new OttoAdapter({
33 | auth: { apiKey: "dk_anything" },
34 | db: "",
35 | server: "https://example.com",
36 | }),
37 | ).toThrow();
38 | });
39 | test("without auth", () => {
40 | expect(() =>
41 | DataApi({
42 | // @ts-expect-error the auth object is missing properties
43 | auth: {},
44 | db: "anything",
45 | server: "https://example.com",
46 | tokenStore: memoryStore(),
47 | }),
48 | ).toThrow();
49 | });
50 |
51 | test("without password", () => {
52 | expect(() =>
53 | DataApi({
54 | adapter: new FetchAdapter({
55 | auth: { username: "anything", password: "" },
56 | db: "anything",
57 | server: "https://example.com",
58 | tokenStore: memoryStore(),
59 | }),
60 | }),
61 | ).toThrow();
62 | });
63 | test("without username", () => {
64 | expect(() =>
65 | DataApi({
66 | adapter: new FetchAdapter({
67 | auth: { username: "", password: "anything" },
68 | db: "anything",
69 | server: "https://example.com",
70 | tokenStore: memoryStore(),
71 | }),
72 | }),
73 | ).toThrow();
74 | });
75 | test("without apiKey", () => {
76 | expect(() =>
77 | DataApi({
78 | adapter: new OttoAdapter({
79 | // @ts-expect-error invalid api KEY
80 | auth: { apiKey: "" },
81 | db: "anything",
82 | server: "https://example.com",
83 | }),
84 | }),
85 | ).toThrow();
86 | });
87 | test("with too much auth (otto3)", () => {
88 | const client = DataApi({
89 | adapter: new OttoAdapter({
90 | auth: {
91 | apiKey: "KEY_anything",
92 | // @ts-expect-error too much auth
93 | username: "anything",
94 | password: "anything",
95 | },
96 | db: "anything",
97 | server: "https://example.com",
98 | }),
99 | });
100 | expect(client.baseUrl.toString()).toContain(":3030");
101 | });
102 | test("with too much auth (otto4)", () => {
103 | const client = DataApi({
104 | adapter: new OttoAdapter({
105 | auth: {
106 | apiKey: "dk_anything",
107 | // @ts-expect-error too much auth
108 | username: "anything",
109 | password: "anything",
110 | },
111 | db: "anything",
112 | server: "https://example.com",
113 | }),
114 | });
115 | expect(client.baseUrl.toString()).toContain("/otto/");
116 | });
117 | });
118 |
119 | describe("client methods (otto 4)", () => {
120 | test("list", async () => {
121 | await client.list({ layout: "layout" });
122 | });
123 | test("list with limit param", async () => {
124 | await client.list({ layout: "layout", limit: 1 });
125 | });
126 | test("missing layout should error", async () => {
127 | await client.list({ layout: "not_a_layout" }).catch((err) => {
128 | expect(err).toBeInstanceOf(FileMakerError);
129 | expect(err.code).toBe("105"); // missing layout error
130 | });
131 | });
132 | });
133 |
--------------------------------------------------------------------------------
/test/setup.ts:
--------------------------------------------------------------------------------
1 | import { OttoFMSAPIKey } from "../src/adapters/otto";
2 | import { DataApi, FetchAdapter, OttoAdapter } from "../src/index";
3 |
4 | import dotenv from "dotenv";
5 | dotenv.config({ path: ".env.local" });
6 |
7 | if (
8 | !process.env.FM_SERVER ||
9 | !process.env.FM_DATABASE ||
10 | !process.env.OTTO_API_KEY
11 | )
12 | throw new Error(
13 | "FM_SERVER, FM_DATABASE, and OTTO_API_KEY must be set in the environment",
14 | );
15 |
16 | if (!process.env.FM_USERNAME || !process.env.FM_PASSWORD)
17 | throw new Error("FM_USERNAME and FM_PASSWORD must be set in the environment");
18 |
19 | export const config = {
20 | auth: { apiKey: process.env.OTTO_API_KEY as OttoFMSAPIKey },
21 | db: process.env.FM_DATABASE,
22 | server: process.env.FM_SERVER,
23 | };
24 |
25 | export const client = DataApi({
26 | adapter: new OttoAdapter({
27 | auth: { apiKey: process.env.OTTO_API_KEY as OttoFMSAPIKey },
28 | db: process.env.FM_DATABASE,
29 | server: process.env.FM_SERVER,
30 | }),
31 | });
32 | export const layoutClient = DataApi({
33 | adapter: new OttoAdapter({
34 | auth: { apiKey: process.env.OTTO_API_KEY as OttoFMSAPIKey },
35 | db: process.env.FM_DATABASE,
36 | server: process.env.FM_SERVER,
37 | }),
38 | layout: "layout",
39 | });
40 | export const weirdPortalClient = DataApi({
41 | adapter: new OttoAdapter({
42 | auth: { apiKey: process.env.OTTO_API_KEY as OttoFMSAPIKey },
43 | db: process.env.FM_DATABASE,
44 | server: process.env.FM_SERVER,
45 | }),
46 | layout: "Weird Portals",
47 | });
48 |
49 | export const containerClient = DataApi<
50 | any,
51 | { myContainer: string; repeatingContainer: string }
52 | >({
53 | adapter: new OttoAdapter({
54 | auth: { apiKey: process.env.OTTO_API_KEY as OttoFMSAPIKey },
55 | db: process.env.FM_DATABASE,
56 | server: process.env.FM_SERVER,
57 | }),
58 | layout: "container",
59 | });
60 |
61 | export const fetchClient = DataApi({
62 | adapter: new FetchAdapter({
63 | auth: {
64 | password: process.env.FM_PASSWORD as string,
65 | username: process.env.FM_USERNAME as string,
66 | },
67 | db: config.db,
68 | server: config.server,
69 | }),
70 | });
71 |
--------------------------------------------------------------------------------
/test/tokenStorage.test.ts:
--------------------------------------------------------------------------------
1 | import { DataApi, FetchAdapter } from "../src";
2 | import { upstashTokenStore } from "../src/tokenStore";
3 | import { describe, it } from "vitest";
4 |
5 | describe("TokenStorage", () => {
6 | it("should allow passing upstash to client init", () => {
7 | DataApi({
8 | adapter: new FetchAdapter({
9 | auth: { username: "username", password: "password" },
10 | db: "db",
11 | server: "https://example.com",
12 | tokenStore: upstashTokenStore({
13 | token: "token",
14 | url: "https://example.com",
15 | }),
16 | }),
17 | });
18 | });
19 | it("shoulw not require a token store", () => {
20 | DataApi({
21 | adapter: new FetchAdapter({
22 | auth: { username: "username", password: "password" },
23 | db: "db",
24 | server: "https://example.com",
25 | }),
26 | });
27 | });
28 | });
29 |
--------------------------------------------------------------------------------
/test/typegen.ts:
--------------------------------------------------------------------------------
1 | import { OttoAPIKey } from "../src/index.js";
2 | import { generateTypedClients } from "../src/utils/typegen/index.js";
3 | import type { GenerateSchemaOptions } from "../src/utils/typegen/types.js";
4 |
5 | import dotenv from "dotenv";
6 | dotenv.config({ path: ".env.local" });
7 |
8 | export const config: GenerateSchemaOptions = {
9 | schemas: [
10 | // add your layouts and name schemas here
11 | { layout: "layout", schemaName: "testLayout", valueLists: "allowEmpty" },
12 | { layout: "Weird Portals", schemaName: "weirdPortals" },
13 |
14 | // repeat as needed for each schema...
15 | // { layout: "my_other_layout", schemaName: "MyOtherSchema" },
16 | ],
17 | path: "./test/typegen",
18 | // webviewerScriptName: "webviewer",
19 | envNames: {
20 | auth: { apiKey: "DIFFERENT_OTTO_API_KEY" as OttoAPIKey },
21 | server: "DIFFERENT_FM_SERVER",
22 | db: "DIFFERENT_FM_DATABASE",
23 | },
24 | clientSuffix: "Layout",
25 | };
26 |
27 | generateTypedClients(config);
28 |
--------------------------------------------------------------------------------
/test/typegen/client/index.ts:
--------------------------------------------------------------------------------
1 | export { client as testLayoutLayout } from "./testLayout";
2 | export { client as weirdPortalsLayout } from "./weirdPortals";
3 |
--------------------------------------------------------------------------------
/test/typegen/client/testLayout.ts:
--------------------------------------------------------------------------------
1 |
2 | /**
3 | * Generated by @proofgeist/fmdapi package
4 | * https://github.com/proofgeist/fmdapi
5 | * DO NOT EDIT THIS FILE DIRECTLY. Changes may be overritten
6 | */
7 | import { DataApi, OttoAdapter, type OttoAPIKey } from "@proofgeist/fmdapi";
8 | import { type TtestLayout, ZtestLayout, type TtestLayoutPortals, ZtestLayoutPortals } from "../testLayout";
9 |
10 | // @generated
11 | // prettier-ignore
12 | /* eslint-disable */
13 | if (!process.env.DIFFERENT_FM_DATABASE) throw new Error("Missing env var: DIFFERENT_FM_DATABASE")
14 | if (!process.env.DIFFERENT_FM_SERVER) throw new Error("Missing env var: DIFFERENT_FM_SERVER")
15 | if (!process.env.DIFFERENT_OTTO_API_KEY) throw new Error("Missing env var: DIFFERENT_OTTO_API_KEY")
16 |
17 | export const client = DataApi({
18 | adapter: new OttoAdapter({
19 | auth: { apiKey: process.env.DIFFERENT_OTTO_API_KEY as OttoAPIKey },
20 | db: process.env.DIFFERENT_FM_DATABASE,
21 | server: process.env.DIFFERENT_FM_SERVER,
22 | }),
23 | layout: "layout",
24 | zodValidators: { fieldData: ZtestLayout, portalData: ZtestLayoutPortals },
25 | });
26 |
--------------------------------------------------------------------------------
/test/typegen/client/weirdPortals.ts:
--------------------------------------------------------------------------------
1 |
2 | /**
3 | * Generated by @proofgeist/fmdapi package
4 | * https://github.com/proofgeist/fmdapi
5 | * DO NOT EDIT THIS FILE DIRECTLY. Changes may be overritten
6 | */
7 | import { DataApi, OttoAdapter, type OttoAPIKey } from "@proofgeist/fmdapi";
8 | import { type TweirdPortals, ZweirdPortals, type TweirdPortalsPortals, ZweirdPortalsPortals } from "../weirdPortals";
9 |
10 | // @generated
11 | // prettier-ignore
12 | /* eslint-disable */
13 | if (!process.env.DIFFERENT_FM_DATABASE) throw new Error("Missing env var: DIFFERENT_FM_DATABASE")
14 | if (!process.env.DIFFERENT_FM_SERVER) throw new Error("Missing env var: DIFFERENT_FM_SERVER")
15 | if (!process.env.DIFFERENT_OTTO_API_KEY) throw new Error("Missing env var: DIFFERENT_OTTO_API_KEY")
16 |
17 | export const client = DataApi({
18 | adapter: new OttoAdapter({
19 | auth: { apiKey: process.env.DIFFERENT_OTTO_API_KEY as OttoAPIKey },
20 | db: process.env.DIFFERENT_FM_DATABASE,
21 | server: process.env.DIFFERENT_FM_SERVER,
22 | }),
23 | layout: "Weird Portals",
24 | zodValidators: { fieldData: ZweirdPortals, portalData: ZweirdPortalsPortals },
25 | });
26 |
--------------------------------------------------------------------------------
/test/typegen/testLayout.ts:
--------------------------------------------------------------------------------
1 |
2 | /**
3 | * Generated by @proofgeist/fmdapi package
4 | * https://github.com/proofgeist/fmdapi
5 | * DO NOT EDIT THIS FILE DIRECTLY. Changes may be overritten
6 | */
7 | import { z } from "zod";
8 |
9 | // @generated
10 | // prettier-ignore
11 | /* eslint-disable */
12 | export const Ztest = z.object({
13 | "related::related_field": z.string(),
14 | "related::recordId": z.union([z.string(), z.number()]),
15 | });
16 |
17 | export type Ttest = z.infer;
18 |
19 | export const ZVLYesNo = z.enum(["Yes", "No"]);
20 |
21 | export type TVLYesNo = z.infer;
22 |
23 | export const ZtestLayout = z.object({
24 | "anything": z.string(),
25 | "recordId": z.union([z.string(), z.number()]),
26 | "fieldWithValues": z.enum(["Yes", "No", ""]).catch(""),
27 | });
28 |
29 | export type TtestLayout = z.infer;
30 |
31 | export const ZtestLayoutPortals = z.object({
32 | "test": Ztest
33 | });
34 |
35 | export type TtestLayoutPortals = z.infer;
36 |
--------------------------------------------------------------------------------
/test/typegen/weirdPortals.ts:
--------------------------------------------------------------------------------
1 |
2 | /**
3 | * Generated by @proofgeist/fmdapi package
4 | * https://github.com/proofgeist/fmdapi
5 | * DO NOT EDIT THIS FILE DIRECTLY. Changes may be overritten
6 | */
7 | import { z } from "zod";
8 |
9 | // @generated
10 | // prettier-ignore
11 | /* eslint-disable */
12 | export const Zlong_and_strangeportalNameforTesting = z.object({
13 | "long_and_strange.portalName#forTesting::PrimaryKey": z.string(),
14 | "long_and_strange.portalName#forTesting::CreationTimestamp": z.string(),
15 | "long_and_strange.portalName#forTesting::CreatedBy": z.string(),
16 | "long_and_strange.portalName#forTesting::ModificationTimestamp": z.string(),
17 | "long_and_strange.portalName#forTesting::ModifiedBy": z.string(),
18 | "long_and_strange.portalName#forTesting::related_field": z.string(),
19 | "long_and_strange.portalName#forTesting::recordId": z.union([z.string(), z.number()]),
20 | });
21 |
22 | export type Tlong_and_strangeportalNameforTesting = z.infer;
23 |
24 | export const ZweirdPortals = z.object({
25 | });
26 |
27 | export type TweirdPortals = z.infer;
28 |
29 | export const ZweirdPortalsPortals = z.object({
30 | "long_and_strange.portalName#forTesting": Zlong_and_strangeportalNameforTesting
31 | });
32 |
33 | export type TweirdPortalsPortals = z.infer;
34 |
--------------------------------------------------------------------------------
/test/zod.test.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 | import { DataApi, OttoAdapter } from "../src";
3 | import { z, ZodError } from "zod";
4 | import { ZGetResponse } from "../src/client-types";
5 | import { config } from "./setup";
6 | import { describe, expect, it } from "vitest";
7 |
8 | type TCustomer = {
9 | name: string;
10 | phone: string;
11 | };
12 | const ZCustomer = z.object({ name: z.string(), phone: z.string() });
13 | const ZPortalTable = z.object({
14 | "related::related_field": z.string(),
15 | });
16 |
17 | const ZCustomerPortals = z.object({
18 | PortalTable: ZPortalTable,
19 | });
20 | type TCustomerPortals = z.infer;
21 |
22 | const client = DataApi({
23 | adapter: new OttoAdapter({
24 | auth: config.auth,
25 | db: config.db,
26 | server: config.server,
27 | }),
28 | layout: "customer",
29 | zodValidators: { fieldData: ZCustomer },
30 | });
31 | const clientPortalData = DataApi({
32 | adapter: new OttoAdapter({
33 | auth: config.auth,
34 | db: config.db,
35 | server: config.server,
36 | }),
37 | layout: "customer",
38 | zodValidators: { fieldData: ZCustomer, portalData: ZCustomerPortals },
39 | });
40 |
41 | const record_portals_bad = {
42 | fieldData: {
43 | name: "Fake Name",
44 | phone: "5551231234",
45 | },
46 | portalData: {
47 | PortalTable: [
48 | {
49 | fieldData: {
50 | "related::related_field_bad": "related field data",
51 | },
52 | portalData: {},
53 | recordId: "53",
54 | modId: "3",
55 | },
56 | ],
57 | },
58 | recordId: "5",
59 | modId: "8",
60 | };
61 |
62 | describe("zod validation", () => {
63 | it("should pass validation, allow extra fields", async () => {
64 | await client.list();
65 | });
66 | it("list method: should fail validation when field is missing", async () => {
67 | await expect(
68 | client.list({ layout: "customer_fieldsMissing" }),
69 | ).rejects.toBeInstanceOf(ZodError);
70 | });
71 | it("find method: should properly infer from root type", async () => {
72 | // the following should not error if typed properly
73 | const resp = await client.find({ query: { name: "test" } });
74 | resp.data[0].fieldData.name;
75 | resp.data[0].fieldData.phone;
76 | });
77 | it("client with portal data passed as zod type", async () => {
78 | await clientPortalData
79 | .list()
80 | .then(
81 | (data) =>
82 | data.data[0].portalData.PortalTable[0]["related::related_field"],
83 | )
84 | .catch();
85 | });
86 | it("client with portal data fails validation", async () => {
87 | expect(() =>
88 | ZGetResponse({
89 | fieldData: ZCustomer,
90 | portalData: ZCustomerPortals,
91 | }).parse(record_portals_bad),
92 | ).toThrowError(ZodError);
93 | });
94 | });
95 |
96 | it("should properly type limit/offset in portals", async () => {
97 | await clientPortalData.find({
98 | query: { name: "test" },
99 | portalRanges: { PortalTable: { limit: 500, offset: 5 } },
100 | });
101 | });
102 |
--------------------------------------------------------------------------------
/thunder-tests/_db-backup/thunderCollection.json:
--------------------------------------------------------------------------------
1 | []
2 |
--------------------------------------------------------------------------------
/thunder-tests/_db-backup/thunderEnvironment.json:
--------------------------------------------------------------------------------
1 | []
2 |
--------------------------------------------------------------------------------
/thunder-tests/_db-backup/thunderclient.json:
--------------------------------------------------------------------------------
1 | []
2 |
--------------------------------------------------------------------------------
/thunder-tests/thunderActivity.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "_id": "b05eb992-7179-4c8d-98b3-75031babd2df",
4 | "colId": "history",
5 | "containerId": "",
6 | "name": "https://eric-local.gicloud.net/fmi/data/v2/databases/Contacts.fmp12/sessions",
7 | "url": "https://eric-local.gicloud.net/fmi/data/v2/databases/Contacts.fmp12/sessions",
8 | "method": "POST",
9 | "sortNum": 0,
10 | "created": "2023-04-25T18:22:14.118Z",
11 | "modified": "2023-04-25T18:22:23.652Z",
12 | "headers": [
13 | {
14 | "name": "Content-Type",
15 | "value": "application/json"
16 | }
17 | ],
18 | "params": [],
19 | "auth": {
20 | "type": "basic",
21 | "basic": {
22 | "username": "admin",
23 | "password": "admin"
24 | }
25 | },
26 | "tests": []
27 | },
28 | {
29 | "_id": "6f75a351-c376-4a9e-ab6f-50e2edd01f4c",
30 | "colId": "history",
31 | "containerId": "",
32 | "name": "https://eric-local.gicloud.net/fmi/data/v2/databases/Contacts.fmp12/sessions Copy",
33 | "url": "https://eric-local.gicloud.net/fmi/data/{version}/databases/{database}/layouts/{layout}",
34 | "method": "GET",
35 | "sortNum": 0,
36 | "created": "2023-04-25T18:22:14.118Z",
37 | "modified": "2023-04-25T18:24:17.641Z",
38 | "headers": [
39 | {
40 | "name": "Content-Type",
41 | "value": "application/json"
42 | }
43 | ],
44 | "params": [
45 | {
46 | "name": "version",
47 | "value": "v2",
48 | "isPath": true
49 | },
50 | {
51 | "name": "database",
52 | "value": "Contacts.fmp12",
53 | "isPath": true
54 | },
55 | {
56 | "name": "layout",
57 | "value": "API Contacts",
58 | "isPath": true
59 | }
60 | ],
61 | "auth": {
62 | "type": "bearer",
63 | "bearer": "3723207769dd42eddcdf2a596da6774664eb246b28d752355a9f"
64 | },
65 | "tests": []
66 | }
67 | ]
68 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | /* Base Options: */
4 | "esModuleInterop": true,
5 | "skipLibCheck": true,
6 | "target": "es2022",
7 | "allowJs": true,
8 | "resolveJsonModule": true,
9 | "moduleDetection": "force",
10 | "isolatedModules": true,
11 | "verbatimModuleSyntax": true,
12 |
13 | /* Strictness */
14 | "strict": true,
15 | "noUncheckedIndexedAccess": true,
16 | "noImplicitOverride": true,
17 |
18 | /* If transpiling with TypeScript: */
19 | "module": "NodeNext",
20 | "outDir": "dist",
21 | "rootDir": "src",
22 | "sourceMap": true,
23 |
24 | /* AND if you're building for a library: */
25 | "declaration": true,
26 |
27 | /* AND if you're building for a library in a monorepo: */
28 | "declarationMap": true
29 | },
30 | "exclude": ["*.config.ts", "test", "dist", "schema", "docs"],
31 | "include": ["./src/index.ts", "./src/**/*.ts"]
32 | }
33 |
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vitest/config";
2 |
3 | export default defineConfig({
4 | test: {
5 | testTimeout: 15000, // 15 seconds, since we're making a network call to FM
6 | },
7 | });
8 |
--------------------------------------------------------------------------------