diff --git a/.changeset/file-version-metadata.md b/.changeset/file-version-metadata.md new file mode 100644 index 0000000..62e4236 --- /dev/null +++ b/.changeset/file-version-metadata.md @@ -0,0 +1,27 @@ +--- +'thatopen-services': minor +--- + +Per-version free-JSON metadata for files. Replaces the old single-endpoint `getFileMetadata` with three explicit version-scoped methods aligned with the new backend CRUD on `/item/:id/version/:tag/metadata`. + +**New methods.** + +- `getFileVersionMetadata(fileId, versionTag, params?)` — `GET /item/:id/version/:tag/metadata`. Returns `{}` when the version exists but has no metadata. +- `updateFileVersionMetadata(fileId, versionTag, metadata)` — `PUT …/metadata`. Replaces the version's metadata with the provided object. +- `deleteFileVersionMetadata(fileId, versionTag)` — `DELETE …/metadata`. Clears the version's metadata. + +**New types and constants.** `Metadata = Record`, `MetadataValue = string | number | boolean | null`, and `METADATA_LIMITS` (200 fields, 50-char keys, 50-char values) are exported from the package root. `metadata` is now typed as `Metadata` everywhere it appears: `CreateItemProps`, `UpdateItemProps`, `createVersion`'s optional last argument. + +**Breaking.** `getFileMetadata(itemId, params?)` is removed. It hit `GET /item/:id/metadata`, which has been deleted on the backend in favour of the version-scoped routes. Replace with `getFileVersionMetadata(fileId, versionTag, params?)` — the version tag is now required because metadata is per-version. To target the live version, pass the tag of the latest non-draft version (the equivalent of the old default behaviour). + +**Migration.** + +```ts +// before +const metadata = await client.getFileMetadata(fileId); + +// after +const metadata = await client.getFileVersionMetadata(fileId, 'v1'); +``` + +`createFile`, `updateFile`, and `createVersion` continue to accept an optional `metadata` argument; the only change is the type — values can now be `string | number | boolean | null` instead of just `string`. diff --git a/src/cli/templates/cloud-test/CONTEXT.md b/src/cli/templates/cloud-test/CONTEXT.md index 8ff9fac..8a5e35b 100644 --- a/src/cli/templates/cloud-test/CONTEXT.md +++ b/src/cli/templates/cloud-test/CONTEXT.md @@ -15,7 +15,7 @@ server-side context. It verifies that the platform API and runtime globals work |-------|-----------------| | **Runtime Globals** | thatOpenServices, executionParams, executionReporter, OBC, THREE, fs | | **Folders** | createFolder, getFolder, listFolders, updateFolder, archiveFolder, recoverFolder, downloadFolder | -| **Files** | createFile, getFile, listFiles, downloadFile, getFileMetadata, updateFile, archiveFile, recoverFile | +| **Files** | createFile, getFile, listFiles, downloadFile, getFileVersionMetadata, updateFileVersionMetadata, deleteFileVersionMetadata, updateFile, archiveFile, recoverFile | | **Hidden Files** | createHiddenFile, getHiddenFile, getHiddenFilesByParent, downloadHiddenFile, deleteHiddenFile, deleteHiddenFilesByParent | | **Icons** | uploadItemIcon, getItemIcon, removeItemIcon | | **General Items** | updateItem, createVersion | diff --git a/src/cli/templates/cloud-test/src/main.ts b/src/cli/templates/cloud-test/src/main.ts index 907da33..e37efc4 100644 --- a/src/cli/templates/cloud-test/src/main.ts +++ b/src/cli/templates/cloud-test/src/main.ts @@ -205,12 +205,32 @@ export async function main() { }), ); fileResults.push( - await runTest("getFileMetadata", async () => { + await runTest("getFileVersionMetadata", async () => { assert(!!testFileId, "No file"); - const metadata = await thatOpenServices.getFileMetadata(testFileId); + const metadata = await thatOpenServices.getFileVersionMetadata( + testFileId, + "v1", + ); assert(typeof metadata === "object", "metadata not object"); }), ); + fileResults.push( + await runTest("updateFileVersionMetadata", async () => { + assert(!!testFileId, "No file"); + const result = await thatOpenServices.updateFileVersionMetadata( + testFileId, + "v1", + { discipline: "structural" }, + ); + assert(typeof result === "object", "result not object"); + }), + ); + fileResults.push( + await runTest("deleteFileVersionMetadata", async () => { + assert(!!testFileId, "No file"); + await thatOpenServices.deleteFileVersionMetadata(testFileId, "v1"); + }), + ); fileResults.push( await runTest("updateFile (rename + new version)", async () => { assert(!!testFileId, "No file"); diff --git a/src/cli/templates/test/CONTEXT.md b/src/cli/templates/test/CONTEXT.md index 52e6a64..5cdea51 100644 --- a/src/cli/templates/test/CONTEXT.md +++ b/src/cli/templates/test/CONTEXT.md @@ -21,7 +21,7 @@ The test suite covers every API group in EngineServicesClient: | **Context & Auth** | Validates all context fields are present | | **Projects** | getProject, getProjectData, checkPermission | | **Folders** | createFolder, getFolder, listFolders, updateFolder, archiveFolder, recoverFolder, downloadFolder | -| **Files** | createFile, getFile, listFiles, downloadFile, getFileMetadata, updateFile, archiveFile, recoverFile | +| **Files** | createFile, getFile, listFiles, downloadFile, getFileVersionMetadata, updateFileVersionMetadata, deleteFileVersionMetadata, updateFile, archiveFile, recoverFile | | **Hidden Files** | createHiddenFile, getHiddenFile, getHiddenFilesByParent, downloadHiddenFile, deleteHiddenFile, deleteHiddenFilesByParent | | **Icons** | uploadItemIcon, getItemIcon, removeItemIcon | | **General Items** | updateItem, createVersion | diff --git a/src/cli/templates/test/src/main.ts b/src/cli/templates/test/src/main.ts index 96edf98..3fd3e32 100644 --- a/src/cli/templates/test/src/main.ts +++ b/src/cli/templates/test/src/main.ts @@ -443,12 +443,27 @@ async function runAllTests(resultsEl: HTMLElement, client: PlatformClient, compo }), ); fileResults.push( - await runTest("getFileMetadata", async () => { + await runTest("getFileVersionMetadata", async () => { assert(!!testFileId, "No file"); - const metadata = await client.getFileMetadata(testFileId); + const metadata = await client.getFileVersionMetadata(testFileId, "v1"); assert(typeof metadata === "object", "metadata not object"); }), ); + fileResults.push( + await runTest("updateFileVersionMetadata", async () => { + assert(!!testFileId, "No file"); + const result = await client.updateFileVersionMetadata(testFileId, "v1", { + discipline: "structural", + }); + assert(typeof result === "object", "result not object"); + }), + ); + fileResults.push( + await runTest("deleteFileVersionMetadata", async () => { + assert(!!testFileId, "No file"); + await client.deleteFileVersionMetadata(testFileId, "v1"); + }), + ); fileResults.push( await runTest("updateFile (rename + new version)", async () => { assert(!!testFileId, "No file"); diff --git a/src/core/client.test.ts b/src/core/client.test.ts index 5d2109a..75b8665 100644 --- a/src/core/client.test.ts +++ b/src/core/client.test.ts @@ -226,6 +226,84 @@ describe('EngineServicesClient — HTTP contract', () => { }); }); + describe('file version metadata', () => { + it('GET hits /item/:id/version/:tag/metadata', async () => { + fetchMock.mockResolvedValue(okResponse({ k: 'v' })); + const client = new EngineServicesClient(TOKEN, API); + const result = await client.getFileVersionMetadata('file-1', 'v1'); + const { url, init } = getCall(fetchMock); + const { pathname } = parseUrl(url); + expect(pathname).toBe('/api/item/file-1/version/v1/metadata'); + expect(init.method).toBe('GET'); + expect(result).toEqual({ k: 'v' }); + }); + + it('GET forwards withDraft when provided', async () => { + fetchMock.mockResolvedValue(okResponse({})); + const client = new EngineServicesClient(TOKEN, API); + await client.getFileVersionMetadata('file-1', 'draft', { + withDraft: true, + }); + const { url } = getCall(fetchMock); + const { params } = parseUrl(url); + expect(params.get('withDraft')).toBe('true'); + }); + + it('PUT sends the metadata in a JSON body', async () => { + fetchMock.mockResolvedValue(okResponse({ a: 'b' })); + const client = new EngineServicesClient(TOKEN, API); + await client.updateFileVersionMetadata('file-1', 'v1', { a: 'b', n: 1 }); + const { url, init } = getCall(fetchMock); + const { pathname } = parseUrl(url); + expect(pathname).toBe('/api/item/file-1/version/v1/metadata'); + expect(init.method).toBe('PUT'); + expect(JSON.parse(init.body as string)).toEqual({ + metadata: { a: 'b', n: 1 }, + }); + expect((init.headers as Record)['Content-Type']).toBe( + 'application/json', + ); + }); + + it('DELETE hits /item/:id/version/:tag/metadata with DELETE method', async () => { + fetchMock.mockResolvedValue(okResponse({ success: true })); + const client = new EngineServicesClient(TOKEN, API); + await client.deleteFileVersionMetadata('file-1', 'v1'); + const { url, init } = getCall(fetchMock); + const { pathname } = parseUrl(url); + expect(pathname).toBe('/api/item/file-1/version/v1/metadata'); + expect(init.method).toBe('DELETE'); + }); + + it('createFile attaches metadata to the FormData body when provided', async () => { + fetchMock.mockResolvedValue(okResponse({})); + const client = new EngineServicesClient(TOKEN, API); + const file = new Blob(['x']) as Blob; + await client.createFile({ + file, + name: 'doc.ifc', + versionTag: 'v1', + metadata: { discipline: 'structural' }, + }); + const { init } = getCall(fetchMock); + const formData = init.body as FormData; + expect(JSON.parse(formData.get('metadata') as string)).toEqual({ + discipline: 'structural', + }); + }); + + it('encodes URL-unsafe characters in fileId and versionTag', async () => { + fetchMock.mockResolvedValue(okResponse({})); + const client = new EngineServicesClient(TOKEN, API); + await client.getFileVersionMetadata('file/with slash', 'v1?bug'); + const { url } = getCall(fetchMock); + const { pathname } = parseUrl(url); + expect(pathname).toBe( + '/api/item/file%2Fwith%20slash/version/v1%3Fbug/metadata', + ); + }); + }); + describe('version archive / recover / delete', () => { it('listVersions GETs /item/:id/versions and forwards archived filter', async () => { fetchMock.mockResolvedValue(okResponse([])); diff --git a/src/core/client.ts b/src/core/client.ts index f506136..7f3e332 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -16,7 +16,11 @@ import { ItemWithVersions, } from '../types/items'; import { CreateItemResponse, UpdateItemResponse } from '../types/response'; -import { CreateHiddenItemResult, HiddenFileEntity } from '../types/files'; +import { + CreateHiddenItemResult, + HiddenFileEntity, + Metadata, +} from '../types/files'; import { ThatOpenContext } from '../types/context'; declare global { @@ -56,8 +60,8 @@ export type CreateItemProps = { parentFolderId?: string; /** Optional project ID to associate the item with. */ projectId?: string; - /** Optional key-value metadata (max 30 KB when serialized). */ - metadata?: Record; + /** Optional free-JSON metadata stored on the first version. */ + metadata?: Metadata; }; /** Properties for updating an existing item. Combines rename/move with optional new version upload. */ @@ -70,8 +74,8 @@ export type UpdateItemProps = { file?: File | Blob; /** Version tag for the new file version. */ versionTag?: string; - /** Optional key-value metadata for the new version. */ - metadata?: Record; + /** Optional free-JSON metadata stored on the new version. */ + metadata?: Metadata; }; /** Properties for creating an app. Extends {@link CreateItemProps} with app-specific version props. */ @@ -449,7 +453,7 @@ export class EngineServicesClient { /** * Uploads a new file. - * @param fileData - File content, name, version tag, and optional metadata. + * @param fileData - File content, name, and version tag. * @returns The created item and its first version. */ async createFile(fileData: CreateItemProps) { @@ -500,25 +504,62 @@ export class EngineServicesClient { } /** - * Retrieves the metadata JSON associated with a file version. - * @param itemId - The file's unique identifier. - * @param params - Optional version selection parameters. - * @returns The metadata key-value object. + * Retrieves the free-JSON metadata for a specific file version. + * Returns `{}` when the version exists but has no metadata. + * @param fileId - The file's unique identifier. + * @param versionTag - The version tag (e.g. "v1"). + * @param params - Optional flags such as `withDraft`. */ - async getFileMetadata(itemId: string, params?: DownloadItemFileParams) { - const { versionTag, withDraft } = params || {}; - return await this.#requestApi>( + async getFileVersionMetadata( + fileId: string, + versionTag: string, + params?: { withDraft?: boolean }, + ) { + const { withDraft } = params || {}; + return await this.#requestApi( 'GET', - `${ITEM_PATH}/${itemId}/metadata`, + `${ITEM_PATH}/${encodeURIComponent(fileId)}/version/${encodeURIComponent(versionTag)}/metadata`, { query: { - ...(versionTag && { versionTag }), - ...(withDraft && { withDraft }), + ...(withDraft && { withDraft: 'true' }), }, }, ); } + /** + * Replaces the metadata of a specific file version with the provided object. + * @param fileId - The file's unique identifier. + * @param versionTag - The version tag. + * @param metadata - Free-JSON object (max 200 fields, 50-char keys/values). + */ + async updateFileVersionMetadata( + fileId: string, + versionTag: string, + metadata: Metadata, + ) { + return await this.#requestApi( + 'PUT', + `${ITEM_PATH}/${encodeURIComponent(fileId)}/version/${encodeURIComponent(versionTag)}/metadata`, + { + body: JSON.stringify({ metadata }), + contentType: 'application/json', + }, + ); + } + + /** + * Clears all metadata from a specific file version. + * @param fileId - The file's unique identifier. + * @param versionTag - The version tag. + */ + async deleteFileVersionMetadata(fileId: string, versionTag: string) { + return await this.#requestApi<{ success: boolean }>( + 'DELETE', + `${ITEM_PATH}/${encodeURIComponent(fileId)}/version/${encodeURIComponent(versionTag)}/metadata`, + ); + } + // ─── Folders ───────────────────────────────────────────────────── /** @@ -1257,7 +1298,7 @@ export class EngineServicesClient { * @param file - The new file to upload. * @param versionTag - Version tag for the new version (e.g. "v2"). * @param extraProps - Version-specific properties (required for APP/TOOL types). - * @param metadata - Optional key-value metadata for this version. + * @param metadata - Optional free-JSON metadata to store on the new version. * @returns The created version. */ async createVersion( @@ -1265,14 +1306,13 @@ export class EngineServicesClient { file: File | Blob, versionTag: string, extraProps?: object, - metadata?: Record, + metadata?: Metadata, ) { const formData = new FormData(); formData.append('file', file); formData.append('versionTag', versionTag); extraProps && formData.append('extraProps', JSON.stringify(extraProps)); - metadata && - formData.append('metadata', JSON.stringify(this.#cleanData(metadata))); + metadata && formData.append('metadata', JSON.stringify(metadata)); return await this.#requestApi( 'POST', `${ITEM_PATH}/${itemId}/version`, @@ -1380,8 +1420,7 @@ export class EngineServicesClient { projectId && formData.append('projectId', projectId); extraProps && formData.append('extraProps', JSON.stringify(extraProps)); - metadata && - formData.append('metadata', JSON.stringify(this.#cleanData(metadata))); + metadata && formData.append('metadata', JSON.stringify(metadata)); return await this.#requestApi>('POST', ITEM_PATH, { body: formData, }); @@ -1402,8 +1441,7 @@ export class EngineServicesClient { formData.append('file', file); versionTag && formData.append('versionTag', versionTag); extraProps && formData.append('extraProps', JSON.stringify(extraProps)); - metadata && - formData.append('metadata', JSON.stringify(this.#cleanData(metadata))); + metadata && formData.append('metadata', JSON.stringify(metadata)); version = await this.#requestApi( 'POST', `${ITEM_PATH}/${itemId}/version`, diff --git a/src/types/files.ts b/src/types/files.ts index 305a1fe..c28f34f 100644 --- a/src/types/files.ts +++ b/src/types/files.ts @@ -12,3 +12,13 @@ export type HiddenFileEntity = { export type CreateHiddenItemResult = { hiddenFileId: string; }; + +export type MetadataValue = string | number | boolean | null; + +export type Metadata = Record; + +export const METADATA_LIMITS = { + MAX_FIELDS: 200, + MAX_KEY_LENGTH: 50, + MAX_VALUE_LENGTH: 50, +} as const;