├── .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 | --------------------------------------------------------------------------------