Skip to content

Commit 3f89b9a

Browse files
api: refactor, add user endpoints
1 parent 7701b27 commit 3f89b9a

7 files changed

Lines changed: 352 additions & 92 deletions

File tree

src/Deputy.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import DeputyStorage from './DeputyStorage';
33
import DeputyCommunications from './DeputyCommunications';
44
import DeputySession from './session/DeputySession';
55
import DeputyCasePage from './wiki/DeputyCasePage';
6-
import DeputyDispatch from './api/DeputyDispatch';
6+
import Dispatch from './api/Dispatch';
77
import ContributionSurveyRow from './models/ContributionSurveyRow';
88
import performHacks from './wiki/util/performHacks';
99
import DeputyCase from './wiki/DeputyCase';
@@ -40,7 +40,7 @@ class Deputy {
4040
* @private
4141
*/
4242
static instance: Deputy;
43-
readonly DeputyDispatch = DeputyDispatch;
43+
readonly DeputyDispatch = Dispatch;
4444
readonly DeputyStorage = DeputyStorage;
4545
readonly DeputySession = DeputySession;
4646
readonly DeputyCommunications = DeputyCommunications;
@@ -71,7 +71,7 @@ class Deputy {
7171

7272
// Components
7373

74-
dispatch: DeputyDispatch;
74+
dispatch: Dispatch;
7575
storage: DeputyStorage;
7676
comms: DeputyCommunications;
7777
session: DeputySession;
@@ -150,7 +150,7 @@ class Deputy {
150150
this.storage = new DeputyStorage();
151151
await this.storage.init();
152152
// Initialize the Deputy API interface
153-
this.dispatch = new DeputyDispatch();
153+
this.dispatch = Dispatch.i;
154154
// Initialize communications
155155
this.comms = new DeputyCommunications();
156156
this.comms.init();

src/api/DeputyDispatch.ts

Lines changed: 0 additions & 87 deletions
This file was deleted.

src/api/Dispatch.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { ExpandedRevisionData } from './ExpandedRevisionData';
2+
import Requester from '../util/Requester';
3+
import { deputyVersion } from '../DeputyVersion';
4+
5+
/**
6+
* API communication class
7+
*/
8+
export default class Dispatch {
9+
10+
/**
11+
* Singleton instance.
12+
*/
13+
static readonly i = new Dispatch();
14+
15+
/**
16+
* Token used for authentication on the server side. Allows access to deleted
17+
* revisions if the user has proper rights.
18+
*/
19+
static token?: string;
20+
21+
/**
22+
* Creates a Deputy API instance.
23+
*/
24+
private constructor() { /* ignored for now */ }
25+
26+
/**
27+
* Logs the user out of the API.
28+
*/
29+
async logout() {
30+
// TODO: Make logout API request
31+
await window.deputy.storage.setKV( 'api-token', null );
32+
}
33+
34+
/**
35+
* Logs in the user. Optional: only used for getting data on deleted revisions.
36+
*/
37+
async login() {
38+
Dispatch.token = await window.deputy.storage.getKV( 'api-token' );
39+
// TODO: If token, set token
40+
// TODO: If no token, start OAuth flow and make login API request
41+
throw new Error( 'Unimplemented method.' );
42+
}
43+
44+
/**
45+
* Returns a fully-formed HTTP URL from a given endpoint. This uses the wiki's
46+
* set Dispatch endpoint and a given target (such as `/v1/revisions`) to get
47+
* the full URL.
48+
*
49+
* @param endpoint The endpoint to get
50+
*/
51+
async getEndpoint( endpoint: string ): Promise<URL> {
52+
return new URL(
53+
endpoint.replace( /^\/+/, '' ),
54+
( await window.deputy.getWikiConfig() ).core.dispatchRoot.get()
55+
.href
56+
.replace( /\/+$/, '' )
57+
);
58+
}
59+
60+
}

src/api/DispatchAsync.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { deputyVersion } from '../DeputyVersion';
2+
3+
interface DeputyDispatchTaskOptions {
4+
/**
5+
* The rate in milliseconds at which new progress requests are made.
6+
*
7+
* @default 500
8+
*/
9+
refreshRate?: number;
10+
}
11+
12+
/**
13+
*
14+
*/
15+
export class DeputyDispatchTask<T> extends EventTarget implements DeputyDispatchTaskOptions {
16+
17+
id: string;
18+
progress = 0;
19+
finished = false;
20+
baseEndpoint: URL;
21+
promise: Promise<T>;
22+
23+
refreshRate: number;
24+
25+
/**
26+
* @return The endpoint for retrieving this task's progress
27+
*/
28+
get progressEndpoint(): URL {
29+
const u = new URL( this.id + '/progress', this.baseEndpoint );
30+
u.search = '';
31+
return u;
32+
}
33+
34+
/**
35+
* @return The endpoint for retrieving this task's result
36+
*/
37+
get resultEndpoint(): URL {
38+
const u = new URL( this.id, this.baseEndpoint );
39+
u.search = '';
40+
return u;
41+
}
42+
43+
/**
44+
*
45+
* @param endpoint
46+
* @param id
47+
* @param options
48+
*/
49+
constructor( endpoint: URL, id: string, options: DeputyDispatchTaskOptions ) {
50+
super();
51+
this.baseEndpoint = new URL( endpoint );
52+
if ( !this.baseEndpoint.pathname.endsWith( '/' ) ) {
53+
this.baseEndpoint.pathname += '/';
54+
}
55+
this.id = id;
56+
57+
this.refreshRate = options.refreshRate ?? 500;
58+
59+
// Execute last.
60+
this.promise = this.waitUntilDone();
61+
}
62+
63+
/**
64+
* Get the progress of this task.
65+
*
66+
* @return A tuple containing the progress and the `finished` boolean.
67+
*/
68+
async fetchProgress(): Promise<[number, boolean]> {
69+
return fetch( this.progressEndpoint )
70+
.then( r => r.json() )
71+
.then( d => [ d.progress, d.finished ] );
72+
}
73+
74+
/**
75+
* Get the result of this task.
76+
*
77+
* @return The result of the task.
78+
*/
79+
async fetchResult(): Promise<T> {
80+
return fetch( this.resultEndpoint )
81+
.then( r => r.json() );
82+
}
83+
84+
/**
85+
* Wait until this task has finished running.
86+
*/
87+
async waitUntilDone(): Promise<T> {
88+
while ( !this.finished ) {
89+
const [ progress, finished ] = await this.fetchProgress();
90+
if ( progress !== this.progress ) {
91+
this.progress = progress;
92+
this.dispatchEvent( new CustomEvent( 'progress', { detail: progress } ) );
93+
}
94+
if ( finished !== this.finished ) {
95+
this.finished = finished;
96+
this.dispatchEvent( new CustomEvent( 'finished' ) );
97+
}
98+
await new Promise( r => setTimeout( r, this.refreshRate ) );
99+
}
100+
101+
return this.fetchResult();
102+
}
103+
104+
}
105+
106+
/**
107+
* Utility class for making Deputy Dispatch asynchronous requests.
108+
*
109+
* An asynchronous request is a special type of request made to the endpoint. It
110+
* starts with a POST on the target endpoint with the relevant data, which returns
111+
* a special ID. This ID can then be used to periodically poll the server for task
112+
* status, and eventually be used to get the data back from the server.
113+
*/
114+
export default class DispatchAsync {
115+
116+
/**
117+
*
118+
* @param endpoint
119+
* @param options
120+
* @param taskOptions
121+
*/
122+
static async makeRequest<T>(
123+
endpoint: URL,
124+
options: Record<string, any>,
125+
taskOptions: DeputyDispatchTaskOptions = {}
126+
): Promise<DeputyDispatchTask<T>> {
127+
const taskInfo = await fetch( endpoint, {
128+
body: JSON.stringify( options ),
129+
headers: {
130+
'Content-Type': 'application/json',
131+
'Api-User-Agent': `Deputy/${deputyVersion} (${window.location.hostname})`
132+
},
133+
redirect: 'follow'
134+
} )
135+
.then( r => {
136+
if ( r.status !== 202 ) {
137+
throw new Error( `DeputyDispatchAsync: Unexpected status code ${r.status}` );
138+
}
139+
return r;
140+
} )
141+
.then( r => r.json() );
142+
143+
return new DeputyDispatchTask( endpoint, taskInfo.id, taskOptions );
144+
}
145+
146+
}

src/api/DispatchRevisions.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { ExpandedRevisionData } from './ExpandedRevisionData';
2+
import Requester from '../util/Requester';
3+
import Dispatch from './Dispatch';
4+
import { deputyVersion } from '../DeputyVersion';
5+
6+
/**
7+
*
8+
*/
9+
export default class DispatchRevisions {
10+
11+
/**
12+
* Singleton instance
13+
*/
14+
static readonly i = new DispatchRevisions();
15+
/**
16+
*
17+
*/
18+
private constructor() { /* ignored */ }
19+
20+
/**
21+
* Gets expanded revision data from the API. This returns a response similar to the
22+
* `revisions` object provided by action=query, but also includes additional information
23+
* relevant (such as the parsed (HTML) comment, diff size, etc.)
24+
*
25+
* @param revisions The revisions to get the data for
26+
* @return An object of expanded revision data mapped by revision IDs
27+
*/
28+
async get(
29+
revisions: number[]
30+
): Promise<Record<number, ExpandedRevisionData>> {
31+
return Requester.fetch(
32+
await Dispatch.i.getEndpoint(
33+
`v1/revisions/${mw.config.get( 'wgWikiID' )}`
34+
),
35+
{
36+
method: 'POST',
37+
headers: {
38+
'Content-Type': 'application/x-www-form-urlencoded',
39+
'Api-User-Agent': `Deputy/${deputyVersion} (${window.location.hostname})`
40+
},
41+
body: 'revisions=' + revisions.join( '|' )
42+
}
43+
)
44+
.then( ( r ) => r.json() )
45+
.then( ( j ) => {
46+
if ( j.error ) {
47+
throw new Error( j.error.info );
48+
}
49+
50+
return j;
51+
} )
52+
.then( ( j ) => j.revisions );
53+
}
54+
55+
}

0 commit comments

Comments
 (0)