Skip to content

Commit e4fbb63

Browse files
authored
feat: per-version file metadata client API (#14)
* feat: per-version file metadata client API Replace the single `getFileMetadata` call with explicit per-version CRUD matching the new backend endpoints. - Add `Metadata` / `MetadataValue` / `METADATA_LIMITS` exports in `types/files`. - New methods: `getFileVersionMetadata`, `updateFileVersionMetadata`, `deleteFileVersionMetadata`. `metadata` re-added to `CreateItemProps`, `UpdateItemProps`, and `createVersion` with the new typed shape. - Update CLI test templates and CONTEXT.md docs. - Unit tests cover URL/method/body for each new method and the createFile metadata FormData path. * chore: add changeset for per-version metadata API * fix: encodeURIComponent for path segments in metadata methods
1 parent 1b523d4 commit e4fbb63

8 files changed

Lines changed: 218 additions & 30 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'thatopen-services': minor
3+
---
4+
5+
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`.
6+
7+
**New methods.**
8+
9+
- `getFileVersionMetadata(fileId, versionTag, params?)``GET /item/:id/version/:tag/metadata`. Returns `{}` when the version exists but has no metadata.
10+
- `updateFileVersionMetadata(fileId, versionTag, metadata)``PUT …/metadata`. Replaces the version's metadata with the provided object.
11+
- `deleteFileVersionMetadata(fileId, versionTag)``DELETE …/metadata`. Clears the version's metadata.
12+
13+
**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.
14+
15+
**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).
16+
17+
**Migration.**
18+
19+
```ts
20+
// before
21+
const metadata = await client.getFileMetadata(fileId);
22+
23+
// after
24+
const metadata = await client.getFileVersionMetadata(fileId, 'v1');
25+
```
26+
27+
`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`.

src/cli/templates/cloud-test/CONTEXT.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ server-side context. It verifies that the platform API and runtime globals work
1515
|-------|-----------------|
1616
| **Runtime Globals** | thatOpenServices, executionParams, executionReporter, OBC, THREE, fs |
1717
| **Folders** | createFolder, getFolder, listFolders, updateFolder, archiveFolder, recoverFolder, downloadFolder |
18-
| **Files** | createFile, getFile, listFiles, downloadFile, getFileMetadata, updateFile, archiveFile, recoverFile |
18+
| **Files** | createFile, getFile, listFiles, downloadFile, getFileVersionMetadata, updateFileVersionMetadata, deleteFileVersionMetadata, updateFile, archiveFile, recoverFile |
1919
| **Hidden Files** | createHiddenFile, getHiddenFile, getHiddenFilesByParent, downloadHiddenFile, deleteHiddenFile, deleteHiddenFilesByParent |
2020
| **Icons** | uploadItemIcon, getItemIcon, removeItemIcon |
2121
| **General Items** | updateItem, createVersion |

src/cli/templates/cloud-test/src/main.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,12 +205,32 @@ export async function main() {
205205
}),
206206
);
207207
fileResults.push(
208-
await runTest("getFileMetadata", async () => {
208+
await runTest("getFileVersionMetadata", async () => {
209209
assert(!!testFileId, "No file");
210-
const metadata = await thatOpenServices.getFileMetadata(testFileId);
210+
const metadata = await thatOpenServices.getFileVersionMetadata(
211+
testFileId,
212+
"v1",
213+
);
211214
assert(typeof metadata === "object", "metadata not object");
212215
}),
213216
);
217+
fileResults.push(
218+
await runTest("updateFileVersionMetadata", async () => {
219+
assert(!!testFileId, "No file");
220+
const result = await thatOpenServices.updateFileVersionMetadata(
221+
testFileId,
222+
"v1",
223+
{ discipline: "structural" },
224+
);
225+
assert(typeof result === "object", "result not object");
226+
}),
227+
);
228+
fileResults.push(
229+
await runTest("deleteFileVersionMetadata", async () => {
230+
assert(!!testFileId, "No file");
231+
await thatOpenServices.deleteFileVersionMetadata(testFileId, "v1");
232+
}),
233+
);
214234
fileResults.push(
215235
await runTest("updateFile (rename + new version)", async () => {
216236
assert(!!testFileId, "No file");

src/cli/templates/test/CONTEXT.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ The test suite covers every API group in EngineServicesClient:
2121
| **Context & Auth** | Validates all context fields are present |
2222
| **Projects** | getProject, getProjectData, checkPermission |
2323
| **Folders** | createFolder, getFolder, listFolders, updateFolder, archiveFolder, recoverFolder, downloadFolder |
24-
| **Files** | createFile, getFile, listFiles, downloadFile, getFileMetadata, updateFile, archiveFile, recoverFile |
24+
| **Files** | createFile, getFile, listFiles, downloadFile, getFileVersionMetadata, updateFileVersionMetadata, deleteFileVersionMetadata, updateFile, archiveFile, recoverFile |
2525
| **Hidden Files** | createHiddenFile, getHiddenFile, getHiddenFilesByParent, downloadHiddenFile, deleteHiddenFile, deleteHiddenFilesByParent |
2626
| **Icons** | uploadItemIcon, getItemIcon, removeItemIcon |
2727
| **General Items** | updateItem, createVersion |

src/cli/templates/test/src/main.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -443,12 +443,27 @@ async function runAllTests(resultsEl: HTMLElement, client: PlatformClient, compo
443443
}),
444444
);
445445
fileResults.push(
446-
await runTest("getFileMetadata", async () => {
446+
await runTest("getFileVersionMetadata", async () => {
447447
assert(!!testFileId, "No file");
448-
const metadata = await client.getFileMetadata(testFileId);
448+
const metadata = await client.getFileVersionMetadata(testFileId, "v1");
449449
assert(typeof metadata === "object", "metadata not object");
450450
}),
451451
);
452+
fileResults.push(
453+
await runTest("updateFileVersionMetadata", async () => {
454+
assert(!!testFileId, "No file");
455+
const result = await client.updateFileVersionMetadata(testFileId, "v1", {
456+
discipline: "structural",
457+
});
458+
assert(typeof result === "object", "result not object");
459+
}),
460+
);
461+
fileResults.push(
462+
await runTest("deleteFileVersionMetadata", async () => {
463+
assert(!!testFileId, "No file");
464+
await client.deleteFileVersionMetadata(testFileId, "v1");
465+
}),
466+
);
452467
fileResults.push(
453468
await runTest("updateFile (rename + new version)", async () => {
454469
assert(!!testFileId, "No file");

src/core/client.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,84 @@ describe('EngineServicesClient — HTTP contract', () => {
226226
});
227227
});
228228

229+
describe('file version metadata', () => {
230+
it('GET hits /item/:id/version/:tag/metadata', async () => {
231+
fetchMock.mockResolvedValue(okResponse({ k: 'v' }));
232+
const client = new EngineServicesClient(TOKEN, API);
233+
const result = await client.getFileVersionMetadata('file-1', 'v1');
234+
const { url, init } = getCall(fetchMock);
235+
const { pathname } = parseUrl(url);
236+
expect(pathname).toBe('/api/item/file-1/version/v1/metadata');
237+
expect(init.method).toBe('GET');
238+
expect(result).toEqual({ k: 'v' });
239+
});
240+
241+
it('GET forwards withDraft when provided', async () => {
242+
fetchMock.mockResolvedValue(okResponse({}));
243+
const client = new EngineServicesClient(TOKEN, API);
244+
await client.getFileVersionMetadata('file-1', 'draft', {
245+
withDraft: true,
246+
});
247+
const { url } = getCall(fetchMock);
248+
const { params } = parseUrl(url);
249+
expect(params.get('withDraft')).toBe('true');
250+
});
251+
252+
it('PUT sends the metadata in a JSON body', async () => {
253+
fetchMock.mockResolvedValue(okResponse({ a: 'b' }));
254+
const client = new EngineServicesClient(TOKEN, API);
255+
await client.updateFileVersionMetadata('file-1', 'v1', { a: 'b', n: 1 });
256+
const { url, init } = getCall(fetchMock);
257+
const { pathname } = parseUrl(url);
258+
expect(pathname).toBe('/api/item/file-1/version/v1/metadata');
259+
expect(init.method).toBe('PUT');
260+
expect(JSON.parse(init.body as string)).toEqual({
261+
metadata: { a: 'b', n: 1 },
262+
});
263+
expect((init.headers as Record<string, string>)['Content-Type']).toBe(
264+
'application/json',
265+
);
266+
});
267+
268+
it('DELETE hits /item/:id/version/:tag/metadata with DELETE method', async () => {
269+
fetchMock.mockResolvedValue(okResponse({ success: true }));
270+
const client = new EngineServicesClient(TOKEN, API);
271+
await client.deleteFileVersionMetadata('file-1', 'v1');
272+
const { url, init } = getCall(fetchMock);
273+
const { pathname } = parseUrl(url);
274+
expect(pathname).toBe('/api/item/file-1/version/v1/metadata');
275+
expect(init.method).toBe('DELETE');
276+
});
277+
278+
it('createFile attaches metadata to the FormData body when provided', async () => {
279+
fetchMock.mockResolvedValue(okResponse({}));
280+
const client = new EngineServicesClient(TOKEN, API);
281+
const file = new Blob(['x']) as Blob;
282+
await client.createFile({
283+
file,
284+
name: 'doc.ifc',
285+
versionTag: 'v1',
286+
metadata: { discipline: 'structural' },
287+
});
288+
const { init } = getCall(fetchMock);
289+
const formData = init.body as FormData;
290+
expect(JSON.parse(formData.get('metadata') as string)).toEqual({
291+
discipline: 'structural',
292+
});
293+
});
294+
295+
it('encodes URL-unsafe characters in fileId and versionTag', async () => {
296+
fetchMock.mockResolvedValue(okResponse({}));
297+
const client = new EngineServicesClient(TOKEN, API);
298+
await client.getFileVersionMetadata('file/with slash', 'v1?bug');
299+
const { url } = getCall(fetchMock);
300+
const { pathname } = parseUrl(url);
301+
expect(pathname).toBe(
302+
'/api/item/file%2Fwith%20slash/version/v1%3Fbug/metadata',
303+
);
304+
});
305+
});
306+
229307
describe('version archive / recover / delete', () => {
230308
it('listVersions GETs /item/:id/versions and forwards archived filter', async () => {
231309
fetchMock.mockResolvedValue(okResponse([]));

src/core/client.ts

Lines changed: 62 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@ import {
1616
ItemWithVersions,
1717
} from '../types/items';
1818
import { CreateItemResponse, UpdateItemResponse } from '../types/response';
19-
import { CreateHiddenItemResult, HiddenFileEntity } from '../types/files';
19+
import {
20+
CreateHiddenItemResult,
21+
HiddenFileEntity,
22+
Metadata,
23+
} from '../types/files';
2024
import { ThatOpenContext } from '../types/context';
2125

2226
declare global {
@@ -56,8 +60,8 @@ export type CreateItemProps = {
5660
parentFolderId?: string;
5761
/** Optional project ID to associate the item with. */
5862
projectId?: string;
59-
/** Optional key-value metadata (max 30 KB when serialized). */
60-
metadata?: Record<string, string>;
63+
/** Optional free-JSON metadata stored on the first version. */
64+
metadata?: Metadata;
6165
};
6266

6367
/** Properties for updating an existing item. Combines rename/move with optional new version upload. */
@@ -70,8 +74,8 @@ export type UpdateItemProps = {
7074
file?: File | Blob;
7175
/** Version tag for the new file version. */
7276
versionTag?: string;
73-
/** Optional key-value metadata for the new version. */
74-
metadata?: Record<string, string>;
77+
/** Optional free-JSON metadata stored on the new version. */
78+
metadata?: Metadata;
7579
};
7680

7781
/** Properties for creating an app. Extends {@link CreateItemProps} with app-specific version props. */
@@ -449,7 +453,7 @@ export class EngineServicesClient {
449453

450454
/**
451455
* Uploads a new file.
452-
* @param fileData - File content, name, version tag, and optional metadata.
456+
* @param fileData - File content, name, and version tag.
453457
* @returns The created item and its first version.
454458
*/
455459
async createFile(fileData: CreateItemProps) {
@@ -500,25 +504,62 @@ export class EngineServicesClient {
500504
}
501505

502506
/**
503-
* Retrieves the metadata JSON associated with a file version.
504-
* @param itemId - The file's unique identifier.
505-
* @param params - Optional version selection parameters.
506-
* @returns The metadata key-value object.
507+
* Retrieves the free-JSON metadata for a specific file version.
508+
* Returns `{}` when the version exists but has no metadata.
509+
* @param fileId - The file's unique identifier.
510+
* @param versionTag - The version tag (e.g. "v1").
511+
* @param params - Optional flags such as `withDraft`.
507512
*/
508-
async getFileMetadata(itemId: string, params?: DownloadItemFileParams) {
509-
const { versionTag, withDraft } = params || {};
510-
return await this.#requestApi<Record<string, string>>(
513+
async getFileVersionMetadata(
514+
fileId: string,
515+
versionTag: string,
516+
params?: { withDraft?: boolean },
517+
) {
518+
const { withDraft } = params || {};
519+
return await this.#requestApi<Metadata>(
511520
'GET',
512-
`${ITEM_PATH}/${itemId}/metadata`,
521+
`${ITEM_PATH}/${encodeURIComponent(fileId)}/version/${encodeURIComponent(versionTag)}/metadata`,
513522
{
514523
query: {
515-
...(versionTag && { versionTag }),
516-
...(withDraft && { withDraft }),
524+
...(withDraft && { withDraft: 'true' }),
517525
},
518526
},
519527
);
520528
}
521529

530+
/**
531+
* Replaces the metadata of a specific file version with the provided object.
532+
* @param fileId - The file's unique identifier.
533+
* @param versionTag - The version tag.
534+
* @param metadata - Free-JSON object (max 200 fields, 50-char keys/values).
535+
*/
536+
async updateFileVersionMetadata(
537+
fileId: string,
538+
versionTag: string,
539+
metadata: Metadata,
540+
) {
541+
return await this.#requestApi<Metadata>(
542+
'PUT',
543+
`${ITEM_PATH}/${encodeURIComponent(fileId)}/version/${encodeURIComponent(versionTag)}/metadata`,
544+
{
545+
body: JSON.stringify({ metadata }),
546+
contentType: 'application/json',
547+
},
548+
);
549+
}
550+
551+
/**
552+
* Clears all metadata from a specific file version.
553+
* @param fileId - The file's unique identifier.
554+
* @param versionTag - The version tag.
555+
*/
556+
async deleteFileVersionMetadata(fileId: string, versionTag: string) {
557+
return await this.#requestApi<{ success: boolean }>(
558+
'DELETE',
559+
`${ITEM_PATH}/${encodeURIComponent(fileId)}/version/${encodeURIComponent(versionTag)}/metadata`,
560+
);
561+
}
562+
522563
// ─── Folders ─────────────────────────────────────────────────────
523564

524565
/**
@@ -1257,22 +1298,21 @@ export class EngineServicesClient {
12571298
* @param file - The new file to upload.
12581299
* @param versionTag - Version tag for the new version (e.g. "v2").
12591300
* @param extraProps - Version-specific properties (required for APP/TOOL types).
1260-
* @param metadata - Optional key-value metadata for this version.
1301+
* @param metadata - Optional free-JSON metadata to store on the new version.
12611302
* @returns The created version.
12621303
*/
12631304
async createVersion(
12641305
itemId: string,
12651306
file: File | Blob,
12661307
versionTag: string,
12671308
extraProps?: object,
1268-
metadata?: Record<string, string>,
1309+
metadata?: Metadata,
12691310
) {
12701311
const formData = new FormData();
12711312
formData.append('file', file);
12721313
formData.append('versionTag', versionTag);
12731314
extraProps && formData.append('extraProps', JSON.stringify(extraProps));
1274-
metadata &&
1275-
formData.append('metadata', JSON.stringify(this.#cleanData(metadata)));
1315+
metadata && formData.append('metadata', JSON.stringify(metadata));
12761316
return await this.#requestApi<ItemVersion>(
12771317
'POST',
12781318
`${ITEM_PATH}/${itemId}/version`,
@@ -1380,8 +1420,7 @@ export class EngineServicesClient {
13801420
projectId && formData.append('projectId', projectId);
13811421

13821422
extraProps && formData.append('extraProps', JSON.stringify(extraProps));
1383-
metadata &&
1384-
formData.append('metadata', JSON.stringify(this.#cleanData(metadata)));
1423+
metadata && formData.append('metadata', JSON.stringify(metadata));
13851424
return await this.#requestApi<CreateItemResponse<T>>('POST', ITEM_PATH, {
13861425
body: formData,
13871426
});
@@ -1402,8 +1441,7 @@ export class EngineServicesClient {
14021441
formData.append('file', file);
14031442
versionTag && formData.append('versionTag', versionTag);
14041443
extraProps && formData.append('extraProps', JSON.stringify(extraProps));
1405-
metadata &&
1406-
formData.append('metadata', JSON.stringify(this.#cleanData(metadata)));
1444+
metadata && formData.append('metadata', JSON.stringify(metadata));
14071445
version = await this.#requestApi<ItemVersion>(
14081446
'POST',
14091447
`${ITEM_PATH}/${itemId}/version`,

src/types/files.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,13 @@ export type HiddenFileEntity = {
1212
export type CreateHiddenItemResult = {
1313
hiddenFileId: string;
1414
};
15+
16+
export type MetadataValue = string | number | boolean | null;
17+
18+
export type Metadata = Record<string, MetadataValue>;
19+
20+
export const METADATA_LIMITS = {
21+
MAX_FIELDS: 200,
22+
MAX_KEY_LENGTH: 50,
23+
MAX_VALUE_LENGTH: 50,
24+
} as const;

0 commit comments

Comments
 (0)