@proofkit/fmdapi 5.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +110 -0
  3. package/dist/esm/adapters/core.d.ts +55 -0
  4. package/dist/esm/adapters/fetch-base-types.d.ts +7 -0
  5. package/dist/esm/adapters/fetch-base.d.ts +60 -0
  6. package/dist/esm/adapters/fetch-base.js +256 -0
  7. package/dist/esm/adapters/fetch-base.js.map +1 -0
  8. package/dist/esm/adapters/fetch.d.ts +27 -0
  9. package/dist/esm/adapters/fetch.js +79 -0
  10. package/dist/esm/adapters/fetch.js.map +1 -0
  11. package/dist/esm/adapters/otto.d.ts +26 -0
  12. package/dist/esm/adapters/otto.js +29 -0
  13. package/dist/esm/adapters/otto.js.map +1 -0
  14. package/dist/esm/client-types.d.ts +226 -0
  15. package/dist/esm/client-types.js +41 -0
  16. package/dist/esm/client-types.js.map +1 -0
  17. package/dist/esm/client.d.ts +133 -0
  18. package/dist/esm/client.js +295 -0
  19. package/dist/esm/client.js.map +1 -0
  20. package/dist/esm/index.d.ts +8 -0
  21. package/dist/esm/index.js +16 -0
  22. package/dist/esm/index.js.map +1 -0
  23. package/dist/esm/tokenStore/file.d.ts +3 -0
  24. package/dist/esm/tokenStore/index.d.ts +3 -0
  25. package/dist/esm/tokenStore/memory.d.ts +3 -0
  26. package/dist/esm/tokenStore/memory.js +21 -0
  27. package/dist/esm/tokenStore/memory.js.map +1 -0
  28. package/dist/esm/tokenStore/types.d.ts +8 -0
  29. package/dist/esm/tokenStore/upstash.d.ts +6 -0
  30. package/dist/esm/utils.d.ts +8 -0
  31. package/dist/esm/utils.js +16 -0
  32. package/dist/esm/utils.js.map +1 -0
  33. package/package.json +99 -0
  34. package/src/adapters/core.ts +62 -0
  35. package/src/adapters/fetch-base-types.ts +5 -0
  36. package/src/adapters/fetch-base.ts +339 -0
  37. package/src/adapters/fetch.ts +95 -0
  38. package/src/adapters/otto.ts +59 -0
  39. package/src/client-types.ts +296 -0
  40. package/src/client.ts +534 -0
  41. package/src/index.ts +11 -0
  42. package/src/tokenStore/file.ts +33 -0
  43. package/src/tokenStore/index.ts +3 -0
  44. package/src/tokenStore/memory.ts +20 -0
  45. package/src/tokenStore/types.ts +7 -0
  46. package/src/tokenStore/upstash.ts +31 -0
  47. package/src/utils.ts +29 -0
  48. package/stubs/fmschema.config.stub.js +17 -0
  49. package/stubs/fmschema.config.stub.mjs +17 -0
@@ -0,0 +1,339 @@
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 "../client-types.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<string> => {
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<string, string>;
63
+ method?: string;
64
+ retry?: boolean;
65
+ portalRanges?: PortalRanges;
66
+ timeout?: number;
67
+ fetchOptions?: RequestInit;
68
+ }): Promise<unknown> => {
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
+ if (value.offset && value.offset > 0) {
88
+ searchParams.set(
89
+ `_offset.${portalName}`,
90
+ value.offset.toString(),
91
+ );
92
+ }
93
+ if (value.limit) {
94
+ searchParams.set(`_limit.${portalName}`, value.limit.toString());
95
+ }
96
+ }
97
+ }
98
+ }
99
+ if (_sort) {
100
+ searchParams.set("_sort", JSON.stringify(_sort));
101
+ }
102
+ searchParams.delete("portalRanges");
103
+ url.search = searchParams.toString();
104
+ }
105
+
106
+ if (body && "portalRanges" in body) {
107
+ for (const [portalName, value] of Object.entries(
108
+ body.portalRanges as PortalRanges,
109
+ )) {
110
+ if (value) {
111
+ if (value.offset && value.offset > 0) {
112
+ url.searchParams.set(
113
+ `_offset.${portalName}`,
114
+ value.offset.toString(),
115
+ );
116
+ }
117
+ if (value.limit) {
118
+ url.searchParams.set(
119
+ `_limit.${portalName}`,
120
+ value.limit.toString(),
121
+ );
122
+ }
123
+ }
124
+ }
125
+ delete body.portalRanges;
126
+ }
127
+
128
+ const controller = new AbortController();
129
+ let timeout: NodeJS.Timeout | null = null;
130
+ if (params.timeout)
131
+ timeout = setTimeout(() => controller.abort(), params.timeout);
132
+
133
+ const token = await this.getToken({ refresh: retry });
134
+
135
+ const headers = new Headers(fetchOptions?.headers);
136
+ headers.set("Authorization", `Bearer ${token}`);
137
+
138
+ // Only set Content-Type for JSON bodies
139
+ if (!(body instanceof FormData)) {
140
+ headers.set("Content-Type", "application/json");
141
+ }
142
+
143
+ const res = await fetch(url.toString(), {
144
+ ...fetchOptions,
145
+ method,
146
+ body:
147
+ body instanceof FormData
148
+ ? body
149
+ : body
150
+ ? JSON.stringify(body)
151
+ : undefined,
152
+ headers,
153
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
154
+ // @ts-ignore
155
+ signal: controller.signal,
156
+ });
157
+
158
+ if (timeout) clearTimeout(timeout);
159
+
160
+ let respData: RawFMResponse;
161
+ try {
162
+ respData = await res.json();
163
+ } catch {
164
+ respData = {};
165
+ }
166
+
167
+ if (!res.ok) {
168
+ if (
169
+ respData?.messages?.[0].code === "952" &&
170
+ !retry &&
171
+ this.refreshToken
172
+ ) {
173
+ // token expired, get new token and retry once
174
+ return this.request({ ...params, retry: true });
175
+ } else {
176
+ throw new FileMakerError(
177
+ respData?.messages?.[0].code ?? "500",
178
+ `Filemaker Data API failed with (${res.status}): ${JSON.stringify(
179
+ respData,
180
+ null,
181
+ 2,
182
+ )}`,
183
+ );
184
+ }
185
+ }
186
+
187
+ return respData.response;
188
+ };
189
+
190
+ public list = async (opts: ListOptions): Promise<GetResponse> => {
191
+ const { data, layout } = opts;
192
+
193
+ const resp = await this.request({
194
+ url: `/layouts/${layout}/records`,
195
+ query: data as Record<string, string>,
196
+ fetchOptions: opts.fetch,
197
+ timeout: opts.timeout,
198
+ });
199
+ return resp as GetResponse;
200
+ };
201
+
202
+ public get = async (opts: GetOptions): Promise<GetResponse> => {
203
+ const { data, layout } = opts;
204
+ const resp = await this.request({
205
+ url: `/layouts/${layout}/records/${data.recordId}`,
206
+ fetchOptions: opts.fetch,
207
+ timeout: opts.timeout,
208
+ });
209
+ return resp as GetResponse;
210
+ };
211
+
212
+ public find = async (opts: FindOptions): Promise<GetResponse> => {
213
+ const { data, layout } = opts;
214
+ const resp = await this.request({
215
+ url: `/layouts/${layout}/_find`,
216
+ body: data,
217
+ method: "POST",
218
+ fetchOptions: opts.fetch,
219
+ timeout: opts.timeout,
220
+ });
221
+ return resp as GetResponse;
222
+ };
223
+
224
+ public create = async (opts: CreateOptions): Promise<CreateResponse> => {
225
+ const { data, layout } = opts;
226
+ const resp = await this.request({
227
+ url: `/layouts/${layout}/records`,
228
+ body: data,
229
+ method: "POST",
230
+ fetchOptions: opts.fetch,
231
+ timeout: opts.timeout,
232
+ });
233
+ return resp as CreateResponse;
234
+ };
235
+
236
+ public update = async (opts: UpdateOptions): Promise<UpdateResponse> => {
237
+ const {
238
+ data: { recordId, ...data },
239
+ layout,
240
+ } = opts;
241
+ const resp = await this.request({
242
+ url: `/layouts/${layout}/records/${recordId}`,
243
+ body: data,
244
+ method: "PATCH",
245
+ fetchOptions: opts.fetch,
246
+ timeout: opts.timeout,
247
+ });
248
+ return resp as UpdateResponse;
249
+ };
250
+
251
+ public delete = async (opts: DeleteOptions): Promise<DeleteResponse> => {
252
+ const { data, layout } = opts;
253
+ const resp = await this.request({
254
+ url: `/layouts/${layout}/records/${data.recordId}`,
255
+ method: "DELETE",
256
+ fetchOptions: opts.fetch,
257
+ timeout: opts.timeout,
258
+ });
259
+ return resp as DeleteResponse;
260
+ };
261
+
262
+ public layoutMetadata = async (
263
+ opts: LayoutMetadataOptions,
264
+ ): Promise<LayoutMetadataResponse> => {
265
+ return (await this.request({
266
+ url: `/layouts/${opts.layout}`,
267
+ fetchOptions: opts.fetch,
268
+ timeout: opts.timeout,
269
+ })) as LayoutMetadataResponse;
270
+ };
271
+
272
+ /**
273
+ * Execute a script within the database
274
+ */
275
+ public executeScript = async (opts: ExecuteScriptOptions) => {
276
+ const { script, scriptParam, layout } = opts;
277
+ const resp = await this.request({
278
+ url: `/layouts/${layout}/script/${script}`,
279
+ query: scriptParam ? { "script.param": scriptParam } : undefined,
280
+ fetchOptions: opts.fetch,
281
+ timeout: opts.timeout,
282
+ });
283
+ return resp as ScriptResponse;
284
+ };
285
+
286
+ /**
287
+ * Returns a list of available layouts on the database.
288
+ */
289
+ public layouts = async (opts?: Omit<BaseRequest, "layout">) => {
290
+ return (await this.request({
291
+ url: "/layouts",
292
+ fetchOptions: opts?.fetch,
293
+ timeout: opts?.timeout,
294
+ })) as AllLayoutsMetadataResponse;
295
+ };
296
+
297
+ /**
298
+ * Returns a list of available scripts on the database.
299
+ */
300
+ public scripts = async (opts?: Omit<BaseRequest, "layout">) => {
301
+ return (await this.request({
302
+ url: "/scripts",
303
+ fetchOptions: opts?.fetch,
304
+ timeout: opts?.timeout,
305
+ })) as ScriptsMetadataResponse;
306
+ };
307
+
308
+ public containerUpload = async (opts: ContainerUploadOptions) => {
309
+ let url = `/layouts/${opts.layout}/records/${opts.data.recordId}/containers/${opts.data.containerFieldName}`;
310
+ if (opts.data.repetition) url += `/${opts.data.repetition}`;
311
+ const formData = new FormData();
312
+ formData.append("upload", opts.data.file);
313
+
314
+ await this.request({
315
+ url,
316
+ method: "POST",
317
+ body: formData,
318
+ timeout: opts.timeout,
319
+ fetchOptions: opts.fetch,
320
+ });
321
+ };
322
+
323
+ /**
324
+ * Set global fields for the current session
325
+ */
326
+ public globals = async (
327
+ opts: Omit<BaseRequest, "layout"> & {
328
+ globalFields: Record<string, string | number>;
329
+ },
330
+ ) => {
331
+ return (await this.request({
332
+ url: "/globals",
333
+ method: "PATCH",
334
+ body: { globalFields: opts.globalFields },
335
+ fetchOptions: opts?.fetch,
336
+ timeout: opts?.timeout,
337
+ })) as Record<string, never>;
338
+ };
339
+ }
@@ -0,0 +1,95 @@
1
+ import { FileMakerError } from "../client-types.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<TokenStoreDefinitions, "getKey">;
22
+ private getTokenKey: Required<TokenStoreDefinitions>["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<string> => {
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<void> => {
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
+ }
@@ -0,0 +1,59 @@
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<string> => {
57
+ return this.apiKey;
58
+ };
59
+ }