Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/file-version-metadata.md
Original file line number Diff line number Diff line change
@@ -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<string, MetadataValue>`, `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`.
2 changes: 1 addition & 1 deletion src/cli/templates/cloud-test/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
24 changes: 22 additions & 2 deletions src/cli/templates/cloud-test/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
2 changes: 1 addition & 1 deletion src/cli/templates/test/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
19 changes: 17 additions & 2 deletions src/cli/templates/test/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
78 changes: 78 additions & 0 deletions src/core/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>)['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([]));
Expand Down
86 changes: 62 additions & 24 deletions src/core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, string>;
/** 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. */
Expand All @@ -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<string, string>;
/** Optional free-JSON metadata stored on the new version. */
metadata?: Metadata;
};

/** Properties for creating an app. Extends {@link CreateItemProps} with app-specific version props. */
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<Record<string, string>>(
async getFileVersionMetadata(
fileId: string,
versionTag: string,
params?: { withDraft?: boolean },
) {
const { withDraft } = params || {};
return await this.#requestApi<Metadata>(
'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<Metadata>(
'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 ─────────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -1257,22 +1298,21 @@ 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(
itemId: string,
file: File | Blob,
versionTag: string,
extraProps?: object,
metadata?: Record<string, string>,
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<ItemVersion>(
'POST',
`${ITEM_PATH}/${itemId}/version`,
Expand Down Expand Up @@ -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<CreateItemResponse<T>>('POST', ITEM_PATH, {
body: formData,
});
Expand All @@ -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<ItemVersion>(
'POST',
`${ITEM_PATH}/${itemId}/version`,
Expand Down
10 changes: 10 additions & 0 deletions src/types/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,13 @@ export type HiddenFileEntity = {
export type CreateHiddenItemResult = {
hiddenFileId: string;
};

export type MetadataValue = string | number | boolean | null;

export type Metadata = Record<string, MetadataValue>;

export const METADATA_LIMITS = {
MAX_FIELDS: 200,
MAX_KEY_LENGTH: 50,
MAX_VALUE_LENGTH: 50,
} as const;
Loading