Skip to content

Commit c3740de

Browse files
authored
Merge pull request #208 from proofsh/fix/batch-sub-request-urls
2 parents 06173e6 + 3584bff commit c3740de

4 files changed

Lines changed: 86 additions & 14 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@proofkit/fmodata": patch
3+
---
4+
5+
Fix batch sub-request URLs to use canonical FileMaker OData path format. Strips the Otto proxy prefix (`/otto/`) and `.fmp12` file extension from database names in sub-request URLs inside multipart batch bodies, which are processed directly by FileMaker's OData engine. Also fix `InvalidLocationHeaderError` in batch insert/update sub-responses by gracefully handling missing Location headers (returns ROWID -1 instead of throwing).

packages/fmodata/src/client/batch-request.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ const BOUNDARY_REGEX = /boundary=([^;]+)/;
1010
const HTTP_STATUS_LINE_REGEX = /HTTP\/\d\.\d\s+(\d+)\s*(.*)/;
1111
const CRLF_REGEX = /\r\n/;
1212
const CHANGESET_CONTENT_TYPE_REGEX = /Content-Type: multipart\/mixed;\s*boundary=([^\r\n]+)/;
13+
const OTTO_PREFIX_REGEX = /^\/otto/;
14+
const FMPRO_EXT_REGEX = /\.fmp12/;
1315

1416
export interface RequestConfig {
1517
method: string;
@@ -62,6 +64,18 @@ async function requestToConfig(request: Request): Promise<RequestConfig> {
6264
};
6365
}
6466

67+
/**
68+
* Transforms a full URL into the canonical path format required by FileMaker's
69+
* OData batch processor. Strips proxy prefixes (e.g. /otto/) and the .fmp12
70+
* file extension from the database name segment.
71+
*/
72+
export function toBatchSubRequestUrl(fullUrl: string): string {
73+
const url = new URL(fullUrl);
74+
const path = url.pathname.replace(OTTO_PREFIX_REGEX, "");
75+
const batchPath = path.replace(FMPRO_EXT_REGEX, "");
76+
return `${batchPath}${url.search}`;
77+
}
78+
6579
/**
6680
* Formats a single HTTP request for inclusion in a batch
6781
* @param request - The request configuration
@@ -80,11 +94,15 @@ function formatSubRequest(request: RequestConfig, baseUrl: string): string {
8094
lines.push("Content-Transfer-Encoding: binary");
8195
lines.push(""); // Empty line after multipart headers
8296

83-
// Construct full URL (convert relative to absolute)
97+
// Construct sub-request URL as a canonical FileMaker OData path.
98+
// Sub-requests inside the batch body are processed directly by FileMaker's
99+
// OData engine, so they must not include proxy prefixes (e.g. /otto/) or
100+
// the .fmp12 file extension on the database name.
84101
const fullUrl = request.url.startsWith("http") ? request.url : `${baseUrl}${request.url}`;
102+
const subRequestUrl = toBatchSubRequestUrl(fullUrl);
85103

86104
// Add HTTP request line
87-
lines.push(`${request.method} ${fullUrl} HTTP/1.1`);
105+
lines.push(`${request.method} ${subRequestUrl} HTTP/1.1`);
88106

89107
// For requests with body, add headers
90108
if (request.body) {

packages/fmodata/src/client/insert-builder.ts

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -275,14 +275,9 @@ export class InsertBuilder<
275275
// Check for Location header (for return=minimal)
276276
if (this.returnPreference === "minimal") {
277277
const locationHeader = getLocationHeader(response.headers);
278-
if (locationHeader) {
279-
const rowid = this.parseLocationHeader(locationHeader);
280-
// biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type
281-
return { data: { ROWID: rowid } as any, error: undefined };
282-
}
283-
throw new InvalidLocationHeaderError(
284-
"Location header is required when using return=minimal but was not found in response",
285-
);
278+
const rowid = locationHeader ? this.parseLocationHeader(locationHeader) : -1;
279+
// biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type
280+
return { data: { ROWID: rowid } as any, error: undefined };
286281
}
287282

288283
// For 204 responses without return=minimal, FileMaker doesn't return the created entity
@@ -295,11 +290,14 @@ export class InsertBuilder<
295290
};
296291
}
297292

298-
// If we expected return=minimal but got a body, that's unexpected
293+
// If we expected return=minimal but got a body (e.g. batch sub-responses
294+
// where FM returns 204-with-body, converted to 200 by parsedToResponse),
295+
// try to extract ROWID from the Location header or return -1.
299296
if (this.returnPreference === "minimal") {
300-
throw new InvalidLocationHeaderError(
301-
"Expected 204 No Content for return=minimal, but received response with body",
302-
);
297+
const locationHeader = getLocationHeader(response.headers);
298+
const rowid = locationHeader ? this.parseLocationHeader(locationHeader) : -1;
299+
// biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type
300+
return { data: { ROWID: rowid } as any, error: undefined };
303301
}
304302

305303
// Use safeJsonParse to handle FileMaker's invalid JSON with unquoted ? values
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { describe, expect, it } from "vitest";
2+
import { formatBatchRequest, toBatchSubRequestUrl } from "../src/client/batch-request";
3+
4+
describe("toBatchSubRequestUrl", () => {
5+
it("strips /otto/ prefix and .fmp12 extension", () => {
6+
const result = toBatchSubRequestUrl(
7+
"https://host.example.com/otto/fmi/odata/v4/GMT_Web.fmp12/bookings?$top=1&$select=_GMTNum",
8+
);
9+
expect(result).toBe("/fmi/odata/v4/GMT_Web/bookings?$top=1&$select=_GMTNum");
10+
});
11+
12+
it("strips .fmp12 extension without /otto/ prefix", () => {
13+
const result = toBatchSubRequestUrl("https://host.example.com/fmi/odata/v4/GMT_Web.fmp12/bookings");
14+
expect(result).toBe("/fmi/odata/v4/GMT_Web/bookings");
15+
});
16+
17+
it("handles URLs without /otto/ or .fmp12", () => {
18+
const result = toBatchSubRequestUrl("https://host.example.com/fmi/odata/v4/MyDB/contacts");
19+
expect(result).toBe("/fmi/odata/v4/MyDB/contacts");
20+
});
21+
22+
it("preserves query parameters", () => {
23+
const result = toBatchSubRequestUrl(
24+
"https://host.example.com/otto/fmi/odata/v4/MyDB.fmp12/contacts?$filter=name eq 'test'&$top=10",
25+
);
26+
expect(result).toBe("/fmi/odata/v4/MyDB/contacts?$filter=name%20eq%20%27test%27&$top=10");
27+
});
28+
});
29+
30+
describe("formatBatchRequest sub-request URLs", () => {
31+
it("uses canonical paths without /otto/ prefix or .fmp12 in sub-requests", () => {
32+
const baseUrl = "https://host.example.com/otto/fmi/odata/v4/GMT_Web.fmp12";
33+
const { body } = formatBatchRequest(
34+
[{ method: "GET", url: `${baseUrl}/bookings?$top=1&$select=_GMTNum` }],
35+
baseUrl,
36+
);
37+
38+
// The sub-request line must use the canonical path
39+
expect(body).toContain("GET /fmi/odata/v4/GMT_Web/bookings?$top=1&$select=_GMTNum HTTP/1.1");
40+
// Must NOT contain the otto prefix or .fmp12 in the request line
41+
expect(body).not.toContain("/otto/");
42+
expect(body).not.toContain(".fmp12");
43+
});
44+
45+
it("handles relative URLs by prepending baseUrl then transforming", () => {
46+
const baseUrl = "https://host.example.com/otto/fmi/odata/v4/MyDB.fmp12";
47+
const { body } = formatBatchRequest([{ method: "GET", url: "/contacts?$top=5" }], baseUrl);
48+
49+
expect(body).toContain("GET /fmi/odata/v4/MyDB/contacts?$top=5 HTTP/1.1");
50+
});
51+
});

0 commit comments

Comments
 (0)