Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/api-tokens.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@thatopen/services': minor
---

PlatformClient manages the caller's API tokens: `listTokens`, `getTokenSecret`, `createToken`, `updateToken` and `revokeToken`. A token can be limited to some projects (`projectIds`); without them it acts on every project its owner can reach. New types `AccessToken`, `NewAccessToken` and `AccessTokenPermission`.
5 changes: 5 additions & 0 deletions .changeset/project-documents-export.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@thatopen/services': minor
---

`exportProjectDocuments(projectId, { format? })`: a project's documents as one flat table for BI and spreadsheets — a row per file with its folder path, live version and every metadata field as a `meta.<key>` column; JSON rows by default, CSV text with `format: 'csv'`. Needs the platform's `GET /item/export`.
5 changes: 5 additions & 0 deletions .changeset/project-members-and-roles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@thatopen/services': minor
---

PlatformClient administers a project's people and permissions: `listMembers`, `addMember`, `setMemberRole`, `removeMember`, `inviteMember`, `listInvitations`, `cancelInvitation`, `listRoles`, `createRole`, `updateRole`, `deleteRole` and `getPermissionMatrix` (every role, what it may do — folder grants included — and who has it). `Permission` gains `resourcePermissions` (a grant or removal on one file or folder, optionally for everything under it) and `PermissionSubject` gains `'API'`.
39 changes: 39 additions & 0 deletions src/core/client.export.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest';
import { EngineServicesClient } from './client';

// A project's documents as one flat table, for BI (APOGEA 21-10).
const API = 'https://api.example.com';

describe('exportProjectDocuments', () => {
let fetchMock: Mock;
beforeEach(() => {
fetchMock = vi.fn();
globalThis.fetch = fetchMock as unknown as typeof fetch;
});
afterEach(() => vi.restoreAllMocks());

it('asks for the JSON table by default', async () => {
const table = { columns: ['fileId'], value: [{ fileId: 'a' }] };
fetchMock.mockResolvedValue({
ok: true, status: 200, statusText: 'OK',
text: async () => JSON.stringify(table), json: async () => table,
});
const client = new EngineServicesClient('token', API);
await expect(client.exportProjectDocuments('p1')).resolves.toEqual(table);
const url = new URL(fetchMock.mock.calls[0][0] as string);
expect(url.pathname).toBe('/api/item/export');
expect(url.searchParams.get('projectId')).toBe('p1');
expect(url.searchParams.get('format')).toBeNull();
});

it('answers the CSV as text, without the BOM', async () => {
fetchMock.mockResolvedValue({
ok: true, status: 200, statusText: 'OK',
blob: async () => new Blob(['fileId\r\na']),
headers: new Headers({ 'content-type': 'text/csv' }),
});
const client = new EngineServicesClient('token', API);
await expect(client.exportProjectDocuments('p1', { format: 'csv' })).resolves.toBe('fileId\r\na');
expect(new URL(fetchMock.mock.calls[0][0] as string).searchParams.get('format')).toBe('csv');
});
});
36 changes: 36 additions & 0 deletions src/core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,42 @@ export class EngineServicesClient {
}>('GET', `${ITEM_PATH}/documents`, { query: { projectId } });
}

/**
* A project's documents as one flat table, for BI and spreadsheets
* (`GET /item/export`): a row per file with its folder path, its live
* version and every metadata field of that version as a `meta.<key>`
* column. Only what the caller may read. As JSON rows by default; as CSV
* text (RFC 4180) with `format: 'csv'`, ready for Power BI or Excel. A
* platform from before the route answers 404.
*
* @example
* ```ts
* const { columns, value } = await client.exportProjectDocuments(projectId);
* const csv = await client.exportProjectDocuments(projectId, { format: 'csv' });
* ```
*/
async exportProjectDocuments(
projectId: string,
): Promise<{ columns: string[]; value: Record<string, string | number | boolean | null>[] }>;
async exportProjectDocuments(
projectId: string,
options: { format: 'csv' },
): Promise<string>;
async exportProjectDocuments(projectId: string, options?: { format?: 'csv' | 'json' }) {
if (options?.format === 'csv') {
const blob = await this.#requestApi<Blob>('GET', `${ITEM_PATH}/export`, {
query: { projectId, format: 'csv' },
responseType: 'blob',
});
// (The platform starts it with a BOM for Excel; a string has no use for it.)
return (await blob.text()).replace(/^/, '');
}
return await this.#requestApi<{
columns: string[];
value: Record<string, string | number | boolean | null>[];
}>('GET', `${ITEM_PATH}/export`, { query: { projectId } });
}

/**
* Gets a single file by ID, optionally including version history.
* @param fileId - The file's unique identifier.
Expand Down
102 changes: 102 additions & 0 deletions src/core/platform-client.members.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest';
import { PlatformClient } from './platform-client';

// Members, invitations and roles: the administration half (APOGEA 23-10).
const API = 'https://api.example.com';

function okResponse(data: unknown): Response {
return {
ok: true,
status: 200,
statusText: 'OK',
text: async () => JSON.stringify(data),
json: async () => data,
} as unknown as Response;
}

describe('PlatformClient — members, invitations and roles', () => {
let fetchMock: Mock;

beforeEach(() => {
fetchMock = vi.fn();
globalThis.fetch = fetchMock as unknown as typeof fetch;
});

afterEach(() => {
vi.restoreAllMocks();
});

const call = (k = 0) => {
const [url, init] = fetchMock.mock.calls[k] as [string, RequestInit];
return {
method: init.method,
path: new URL(url).pathname,
body: init.body ? JSON.parse(String(init.body)) : undefined,
};
};

it('invites a person with a role', async () => {
fetchMock.mockResolvedValue(okResponse({ _id: 'i1' }));
await new PlatformClient('jwt', API).inviteMember('p1', 'a@b.com', 'r1');
expect(call()).toEqual({
method: 'POST',
path: '/api/project/p1/invite',
body: { email: 'a@b.com', projectRoleId: 'r1' },
});
});

it('lists and cancels invitations', async () => {
fetchMock.mockResolvedValue(okResponse([]));
const client = new PlatformClient('jwt', API);
await client.listInvitations('p1');
await client.cancelInvitation('p1', 'i1');
expect(call(0)).toMatchObject({ method: 'GET', path: '/api/project/p1/invites' });
expect(call(1)).toMatchObject({ method: 'DELETE', path: '/api/project/p1/invite/i1' });
});

it("sets a member's role, null going back to the default", async () => {
fetchMock.mockResolvedValue(okResponse({}));
await new PlatformClient('jwt', API).setMemberRole('p1', 'u1', null);
expect(call()).toEqual({
method: 'PUT',
path: '/api/project/p1/user/u1',
body: { projectRoleId: null },
});
});

it('creates a role with a folder grant', async () => {
fetchMock.mockResolvedValue(okResponse({ _id: 'r2' }));
const permissions = [
{
subject: 'STORAGE' as const,
action: 'READ' as const,
resourcePermissions: [{ resourceId: 'f1', appliesToDescendants: true }],
},
];
await new PlatformClient('jwt', API).createRole('p1', { name: 'Observer', permissions });
expect(call()).toEqual({
method: 'POST',
path: '/api/project/p1/role',
body: { name: 'Observer', permissions },
});
});

it('puts roles, their permissions and their members in one matrix', async () => {
const roles = [
{ _id: 'r1', name: 'Admin', permissions: [{ subject: 'ALL', action: 'MANAGE' }] },
{ _id: 'r2', name: 'Observer', permissions: [] },
];
const members = [
{ projectUserId: 'pu1', user: { _id: 'a', fullName: 'Ana', email: 'a@x' }, role: { _id: 'r1' } },
{ projectUserId: 'pu2', user: { _id: 'b', fullName: 'Luis', email: 'b@x' }, role: { _id: 'r2' } },
];
fetchMock.mockImplementation(async (url: string) =>
okResponse(new URL(url).pathname.endsWith('/roles') ? roles : members),
);
const matrix = await new PlatformClient('jwt', API).getPermissionMatrix('p1');
expect(matrix.map((row) => [row.role.name, row.members.map((m) => m.fullName)])).toEqual([
['Admin', ['Ana']],
['Observer', ['Luis']],
]);
});
});
90 changes: 90 additions & 0 deletions src/core/platform-client.tokens.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import {
describe,
it,
expect,
beforeEach,
afterEach,
vi,
type Mock,
} from 'vitest';
import { PlatformClient } from './platform-client';

// API tokens limited to projects, revocable one by one (APOGEA 23-10, §2.5).
const API = 'https://api.example.com';

function okResponse(data: unknown, text = JSON.stringify(data)): Response {
return {
ok: true,
status: 200,
statusText: 'OK',
text: async () => text,
json: async () => data,
blob: async () => new Blob([text]),
} as unknown as Response;
}

describe('PlatformClient — API tokens', () => {
let fetchMock: Mock;

beforeEach(() => {
fetchMock = vi.fn();
globalThis.fetch = fetchMock as unknown as typeof fetch;
});

afterEach(() => {
vi.restoreAllMocks();
});

const call = (k = 0) => {
const [url, init] = fetchMock.mock.calls[k] as [string, RequestInit];
return {
method: init.method,
path: new URL(url).pathname,
body: init.body ? JSON.parse(String(init.body)) : undefined,
};
};

it('creates a token limited to some projects', async () => {
fetchMock.mockResolvedValue(okResponse({ _id: 't1' }));
await new PlatformClient('jwt', API).createToken({
name: 'BI',
permissions: ['STORAGE:READ'],
projectIds: ['p1', 'p2'],
});
expect(call()).toEqual({
method: 'POST',
path: '/api/tokens',
body: {
name: 'BI',
permissions: ['STORAGE:READ'],
urlRestrictions: [],
projectIds: ['p1', 'p2'],
},
});
});

it('changes, lists and revokes tokens', async () => {
fetchMock.mockResolvedValue(okResponse([]));
const client = new PlatformClient('jwt', API);
await client.updateToken('t1', { projectIds: [] });
await client.listTokens();
await client.revokeToken('t1');
expect(call(0)).toEqual({
method: 'PUT',
path: '/api/tokens/t1',
body: { projectIds: [] },
});
expect(call(1)).toMatchObject({ method: 'GET', path: '/api/tokens' });
expect(call(2)).toMatchObject({ method: 'DELETE', path: '/api/tokens/t1' });
});

it('reads a token secret, which the route answers as bare text', async () => {
fetchMock.mockResolvedValue(okResponse(undefined, 'secret-abc'));
const secret = await new PlatformClient('jwt', API).getTokenSecret('t1');
expect(secret).toBe('secret-abc');
expect(call()).toMatchObject({
method: 'GET',
path: '/api/tokens/t1/token',
});
});
});
Loading
Loading