Skip to content

Commit

Permalink
Add js api (#395)
Browse files Browse the repository at this point in the history
* Add forking API (#394)

* Add forking API

* Add GET forks of root

* Add fork Jupyter events

* Replace query parameter merge=1 with merge=true

* Add fork title and description

* Add JS APIs

---------

Co-authored-by: David Brochart <[email protected]>
  • Loading branch information
trungleduc and davidbrochart committed Nov 29, 2024
1 parent 386cdef commit 7a5613f
Show file tree
Hide file tree
Showing 10 changed files with 414 additions and 4 deletions.
28 changes: 28 additions & 0 deletions packages/docprovider-extension/src/forkManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* Copyright (c) Jupyter Development Team.
* Distributed under the terms of the Modified BSD License.
*/

import { ICollaborativeDrive } from '@jupyter/collaborative-drive';
import {
ForkManager,
IForkManager,
IForkManagerToken
} from '@jupyter/docprovider';

import {
JupyterFrontEnd,
JupyterFrontEndPlugin
} from '@jupyterlab/application';

export const forkManagerPlugin: JupyterFrontEndPlugin<IForkManager> = {
id: '@jupyter/docprovider-extension:forkManager',
autoStart: true,
requires: [ICollaborativeDrive],
provides: IForkManagerToken,
activate: (app: JupyterFrontEnd, drive: ICollaborativeDrive) => {
const eventManager = app.serviceManager.events;
const manager = new ForkManager({ drive, eventManager });
return manager;
}
};
4 changes: 3 additions & 1 deletion packages/docprovider-extension/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
statusBarTimeline
} from './filebrowser';
import { notebookCellExecutor } from './executor';
import { forkManagerPlugin } from './forkManager';

/**
* Export the plugins as default.
Expand All @@ -27,7 +28,8 @@ const plugins: JupyterFrontEndPlugin<any>[] = [
defaultFileBrowser,
logger,
notebookCellExecutor,
statusBarTimeline
statusBarTimeline,
forkManagerPlugin
];

export default plugins;
96 changes: 96 additions & 0 deletions packages/docprovider/src/__tests__/forkManager.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.

import { ICollaborativeDrive } from '@jupyter/collaborative-drive';
import {
ForkManager,
JUPYTER_COLLABORATION_FORK_EVENTS_URI
} from '../forkManager';
import { Event } from '@jupyterlab/services';
import { Signal } from '@lumino/signaling';
import { requestAPI } from '../requests';
jest.mock('../requests');

const driveMock = {
name: 'rtc',
providers: new Map()
} as ICollaborativeDrive;
const stream = new Signal({});
const eventManagerMock = {
stream: stream as any
} as Event.IManager;

describe('@jupyter/docprovider', () => {
let manager: ForkManager;
beforeEach(() => {
manager = new ForkManager({
drive: driveMock,
eventManager: eventManagerMock
});
});
describe('forkManager', () => {
it('should have a type', () => {
expect(ForkManager).not.toBeUndefined();
});
it('should be able to create instance', () => {
expect(manager).toBeInstanceOf(ForkManager);
});
it('should be able to create new fork', async () => {
await manager.createFork({
rootId: 'root-uuid',
synchronize: true,
title: 'my fork label',
description: 'my fork description'
});
expect(requestAPI).toHaveBeenCalledWith(
'api/collaboration/fork/root-uuid',
{
method: 'PUT',
body: JSON.stringify({
title: 'my fork label',
description: 'my fork description',
synchronize: true
})
}
);
});
it('should be able to get all forks', async () => {
await manager.getAllForks('root-uuid');
expect(requestAPI).toHaveBeenCalledWith(
'api/collaboration/fork/root-uuid',
{
method: 'GET'
}
);
});
it('should be able to get delete forks', async () => {
await manager.deleteFork({ forkId: 'fork-uuid', merge: true });
expect(requestAPI).toHaveBeenCalledWith(
'api/collaboration/fork/fork-uuid?merge=true',
{
method: 'DELETE'
}
);
});
it('should be able to emit fork added signal', async () => {
const listener = jest.fn();
manager.forkAdded.connect(listener);
const data = {
schema_id: JUPYTER_COLLABORATION_FORK_EVENTS_URI,
action: 'create'
};
stream.emit(data);
expect(listener).toHaveBeenCalledWith(manager, data);
});
it('should be able to emit fork deleted signal', async () => {
const listener = jest.fn();
manager.forkDeleted.connect(listener);
const data = {
schema_id: JUPYTER_COLLABORATION_FORK_EVENTS_URI,
action: 'delete'
};
stream.emit(data);
expect(listener).toHaveBeenCalledWith(manager, data);
});
});
});
1 change: 0 additions & 1 deletion packages/docprovider/src/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,6 @@ export const TimelineSliderComponent: React.FC<Props> = ({
setData(data);
setCurrentTimestampIndex(data.timestamps.length - 1);
provider.connectToForkDoc(data.forkRoom, data.sessionId);

sessionRef.current = await requestDocSession(
format,
contentType,
Expand Down
126 changes: 126 additions & 0 deletions packages/docprovider/src/forkManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/*
* Copyright (c) Jupyter Development Team.
* Distributed under the terms of the Modified BSD License.
*/

import { ICollaborativeDrive } from '@jupyter/collaborative-drive';
import { URLExt } from '@jupyterlab/coreutils';
import { Event } from '@jupyterlab/services';
import { ISignal, Signal } from '@lumino/signaling';

import { requestAPI, ROOM_FORK_URL } from './requests';
import {
IAllForksResponse,
IForkChangedEvent,
IForkCreationResponse,
IForkManager
} from './tokens';
import { IForkProvider } from './ydrive';

export const JUPYTER_COLLABORATION_FORK_EVENTS_URI =
'https://schema.jupyter.org/jupyter_collaboration/fork/v1';

export class ForkManager implements IForkManager {
constructor(options: ForkManager.IOptions) {
const { drive, eventManager } = options;
this._drive = drive;
this._eventManager = eventManager;
this._eventManager.stream.connect(this._handleEvent, this);
}

get isDisposed(): boolean {
return this._disposed;
}
get forkAdded(): ISignal<ForkManager, IForkChangedEvent> {
return this._forkAddedSignal;
}
get forkDeleted(): ISignal<ForkManager, IForkChangedEvent> {
return this._forkDeletedSignal;
}

dispose(): void {
if (this._disposed) {
return;
}
this._eventManager?.stream.disconnect(this._handleEvent);
this._disposed = true;
}
async createFork(options: {
rootId: string;
synchronize: boolean;
title?: string;
description?: string;
}): Promise<IForkCreationResponse | undefined> {
const { rootId, title, description, synchronize } = options;
const init: RequestInit = {
method: 'PUT',
body: JSON.stringify({ title, description, synchronize })
};
const url = URLExt.join(ROOM_FORK_URL, rootId);
const response = await requestAPI<IForkCreationResponse>(url, init);
return response;
}

async getAllForks(rootId: string) {
const url = URLExt.join(ROOM_FORK_URL, rootId);
const init = { method: 'GET' };
const response = await requestAPI<IAllForksResponse>(url, init);
return response;
}

async deleteFork(options: { forkId: string; merge: boolean }): Promise<void> {
const { forkId, merge } = options;
const url = URLExt.join(ROOM_FORK_URL, forkId);
const query = URLExt.objectToQueryString({ merge });
const init = { method: 'DELETE' };
await requestAPI(`${url}${query}`, init);
}
getProvider(options: {
documentPath: string;
format: string;
type: string;
}): IForkProvider | undefined {
const { documentPath, format, type } = options;
const drive = this._drive;
if (drive) {
const driveName = drive.name;
let docPath = documentPath;
if (documentPath.startsWith(driveName)) {
docPath = documentPath.slice(driveName.length + 1);
}
const provider = drive.providers.get(`${format}:${type}:${docPath}`);
return provider as IForkProvider | undefined;
}
return;
}

private _handleEvent(_: Event.IManager, emission: Event.Emission) {
if (emission.schema_id === JUPYTER_COLLABORATION_FORK_EVENTS_URI) {
switch (emission.action) {
case 'create': {
this._forkAddedSignal.emit(emission as any);
break;
}
case 'delete': {
this._forkDeletedSignal.emit(emission as any);
break;
}
default:
break;
}
}
}

private _disposed = false;
private _drive: ICollaborativeDrive | undefined;
private _eventManager: Event.IManager | undefined;
private _forkAddedSignal = new Signal<ForkManager, IForkChangedEvent>(this);
private _forkDeletedSignal = new Signal<ForkManager, IForkChangedEvent>(this);
}

export namespace ForkManager {
export interface IOptions {
drive: ICollaborativeDrive;
eventManager: Event.IManager;
}
}
2 changes: 2 additions & 0 deletions packages/docprovider/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@ export * from './requests';
export * from './ydrive';
export * from './yprovider';
export * from './TimelineSlider';
export * from './tokens';
export * from './forkManager';
41 changes: 41 additions & 0 deletions packages/docprovider/src/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ const DOC_SESSION_URL = 'api/collaboration/session';
const DOC_FORK_URL = 'api/collaboration/undo_redo';
const TIMELINE_URL = 'api/collaboration/timeline';

export const ROOM_FORK_URL = 'api/collaboration/fork';

/**
* Document session model
*/
Expand All @@ -36,6 +38,45 @@ export interface ISessionModel {
sessionId: string;
}

/**
* Call the API extension
*
* @param endPoint API REST end point for the extension
* @param init Initial values for the request
* @returns The response body interpreted as JSON
*/
export async function requestAPI<T = any>(
endPoint = '',
init: RequestInit = {}
): Promise<T> {
// Make request to Jupyter API
const settings = ServerConnection.makeSettings();
const requestUrl = URLExt.join(settings.baseUrl, endPoint);

let response: Response;
try {
response = await ServerConnection.makeRequest(requestUrl, init, settings);
} catch (error) {
throw new ServerConnection.NetworkError(error as any);
}

let data: any = await response.text();

if (data.length > 0) {
try {
data = JSON.parse(data);
} catch (error) {
console.error('Not a JSON response body.', response);
}
}

if (!response.ok) {
throw new ServerConnection.ResponseError(response, data.message || data);
}

return data;
}

export async function requestDocSession(
format: string,
type: string,
Expand Down
Loading

0 comments on commit 7a5613f

Please sign in to comment.