diff --git a/.changeset/fix-batch-sub-request-urls.md b/.changeset/fix-batch-sub-request-urls.md new file mode 100644 index 00000000..eb142383 --- /dev/null +++ b/.changeset/fix-batch-sub-request-urls.md @@ -0,0 +1,5 @@ +--- +"@proofkit/fmodata": patch +--- + +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). diff --git a/packages/fmodata/src/client/batch-request.ts b/packages/fmodata/src/client/batch-request.ts index 191d1232..402bf10d 100644 --- a/packages/fmodata/src/client/batch-request.ts +++ b/packages/fmodata/src/client/batch-request.ts @@ -10,6 +10,8 @@ const BOUNDARY_REGEX = /boundary=([^;]+)/; const HTTP_STATUS_LINE_REGEX = /HTTP\/\d\.\d\s+(\d+)\s*(.*)/; const CRLF_REGEX = /\r\n/; const CHANGESET_CONTENT_TYPE_REGEX = /Content-Type: multipart\/mixed;\s*boundary=([^\r\n]+)/; +const OTTO_PREFIX_REGEX = /^\/otto/; +const FMPRO_EXT_REGEX = /\.fmp12/; export interface RequestConfig { method: string; @@ -62,6 +64,18 @@ async function requestToConfig(request: Request): Promise { }; } +/** + * Transforms a full URL into the canonical path format required by FileMaker's + * OData batch processor. Strips proxy prefixes (e.g. /otto/) and the .fmp12 + * file extension from the database name segment. + */ +export function toBatchSubRequestUrl(fullUrl: string): string { + const url = new URL(fullUrl); + const path = url.pathname.replace(OTTO_PREFIX_REGEX, ""); + const batchPath = path.replace(FMPRO_EXT_REGEX, ""); + return `${batchPath}${url.search}`; +} + /** * Formats a single HTTP request for inclusion in a batch * @param request - The request configuration @@ -80,11 +94,15 @@ function formatSubRequest(request: RequestConfig, baseUrl: string): string { lines.push("Content-Transfer-Encoding: binary"); lines.push(""); // Empty line after multipart headers - // Construct full URL (convert relative to absolute) + // Construct sub-request URL as a canonical FileMaker OData path. + // Sub-requests inside the batch body are processed directly by FileMaker's + // OData engine, so they must not include proxy prefixes (e.g. /otto/) or + // the .fmp12 file extension on the database name. const fullUrl = request.url.startsWith("http") ? request.url : `${baseUrl}${request.url}`; + const subRequestUrl = toBatchSubRequestUrl(fullUrl); // Add HTTP request line - lines.push(`${request.method} ${fullUrl} HTTP/1.1`); + lines.push(`${request.method} ${subRequestUrl} HTTP/1.1`); // For requests with body, add headers if (request.body) { diff --git a/packages/fmodata/src/client/insert-builder.ts b/packages/fmodata/src/client/insert-builder.ts index 5d75291b..475865ca 100644 --- a/packages/fmodata/src/client/insert-builder.ts +++ b/packages/fmodata/src/client/insert-builder.ts @@ -275,14 +275,9 @@ export class InsertBuilder< // Check for Location header (for return=minimal) if (this.returnPreference === "minimal") { const locationHeader = getLocationHeader(response.headers); - if (locationHeader) { - const rowid = this.parseLocationHeader(locationHeader); - // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type - return { data: { ROWID: rowid } as any, error: undefined }; - } - throw new InvalidLocationHeaderError( - "Location header is required when using return=minimal but was not found in response", - ); + const rowid = locationHeader ? this.parseLocationHeader(locationHeader) : -1; + // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type + return { data: { ROWID: rowid } as any, error: undefined }; } // For 204 responses without return=minimal, FileMaker doesn't return the created entity @@ -295,11 +290,14 @@ export class InsertBuilder< }; } - // If we expected return=minimal but got a body, that's unexpected + // If we expected return=minimal but got a body (e.g. batch sub-responses + // where FM returns 204-with-body, converted to 200 by parsedToResponse), + // try to extract ROWID from the Location header or return -1. if (this.returnPreference === "minimal") { - throw new InvalidLocationHeaderError( - "Expected 204 No Content for return=minimal, but received response with body", - ); + const locationHeader = getLocationHeader(response.headers); + const rowid = locationHeader ? this.parseLocationHeader(locationHeader) : -1; + // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type + return { data: { ROWID: rowid } as any, error: undefined }; } // Use safeJsonParse to handle FileMaker's invalid JSON with unquoted ? values diff --git a/packages/fmodata/tests/batch-sub-request-url.test.ts b/packages/fmodata/tests/batch-sub-request-url.test.ts new file mode 100644 index 00000000..727e06ce --- /dev/null +++ b/packages/fmodata/tests/batch-sub-request-url.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { formatBatchRequest, toBatchSubRequestUrl } from "../src/client/batch-request"; + +describe("toBatchSubRequestUrl", () => { + it("strips /otto/ prefix and .fmp12 extension", () => { + const result = toBatchSubRequestUrl( + "https://host.example.com/otto/fmi/odata/v4/GMT_Web.fmp12/bookings?$top=1&$select=_GMTNum", + ); + expect(result).toBe("/fmi/odata/v4/GMT_Web/bookings?$top=1&$select=_GMTNum"); + }); + + it("strips .fmp12 extension without /otto/ prefix", () => { + const result = toBatchSubRequestUrl("https://host.example.com/fmi/odata/v4/GMT_Web.fmp12/bookings"); + expect(result).toBe("/fmi/odata/v4/GMT_Web/bookings"); + }); + + it("handles URLs without /otto/ or .fmp12", () => { + const result = toBatchSubRequestUrl("https://host.example.com/fmi/odata/v4/MyDB/contacts"); + expect(result).toBe("/fmi/odata/v4/MyDB/contacts"); + }); + + it("preserves query parameters", () => { + const result = toBatchSubRequestUrl( + "https://host.example.com/otto/fmi/odata/v4/MyDB.fmp12/contacts?$filter=name eq 'test'&$top=10", + ); + expect(result).toBe("/fmi/odata/v4/MyDB/contacts?$filter=name%20eq%20%27test%27&$top=10"); + }); +}); + +describe("formatBatchRequest sub-request URLs", () => { + it("uses canonical paths without /otto/ prefix or .fmp12 in sub-requests", () => { + const baseUrl = "https://host.example.com/otto/fmi/odata/v4/GMT_Web.fmp12"; + const { body } = formatBatchRequest( + [{ method: "GET", url: `${baseUrl}/bookings?$top=1&$select=_GMTNum` }], + baseUrl, + ); + + // The sub-request line must use the canonical path + expect(body).toContain("GET /fmi/odata/v4/GMT_Web/bookings?$top=1&$select=_GMTNum HTTP/1.1"); + // Must NOT contain the otto prefix or .fmp12 in the request line + expect(body).not.toContain("/otto/"); + expect(body).not.toContain(".fmp12"); + }); + + it("handles relative URLs by prepending baseUrl then transforming", () => { + const baseUrl = "https://host.example.com/otto/fmi/odata/v4/MyDB.fmp12"; + const { body } = formatBatchRequest([{ method: "GET", url: "/contacts?$top=5" }], baseUrl); + + expect(body).toContain("GET /fmi/odata/v4/MyDB/contacts?$top=5 HTTP/1.1"); + }); +});