m3api-rest 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.browserslistrc +11 -0
- package/CHANGES.md +20 -0
- package/CODE_OF_CONDUCT.md +1 -0
- package/LICENSE +13 -0
- package/README.md +156 -0
- package/index.js +471 -0
- package/jsdoc/conf.json +27 -0
- package/jsdoc/layout.tmpl +38 -0
- package/jsdoc/version-warning.css +27 -0
- package/package.json +46 -0
package/.browserslistrc
ADDED
package/CHANGES.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
This file records the changes in each m3api-rest release.
|
|
4
|
+
|
|
5
|
+
The annotated tag (and GitLab release) for each version also lists the changes,
|
|
6
|
+
but this file may sometimes contain later improvements (e.g. typo fixes).
|
|
7
|
+
|
|
8
|
+
## v0.1.0 (2026-01-01)
|
|
9
|
+
|
|
10
|
+
Initial release, including:
|
|
11
|
+
|
|
12
|
+
- `getJson()` and `postForJson()` functions,
|
|
13
|
+
for making GET and POST requests returning JSON data.
|
|
14
|
+
- ``` path`` ``` template literal tag function,
|
|
15
|
+
for encoding request paths.
|
|
16
|
+
- `getResponseStatus()` function,
|
|
17
|
+
for getting the HTTP status code of a response.
|
|
18
|
+
- `RestApiServerError`, `RestApiClientError`,
|
|
19
|
+
`UnexpectedResponseStatus`, `InvalidResponseBody`,
|
|
20
|
+
and `UnknownResponseError` error classes thrown by these functions.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
The development of this software is covered by a [Code of Conduct](https://www.mediawiki.org/wiki/Special:MyLanguage/Code_of_Conduct).
|
package/LICENSE
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Copyright 2025 Lucas Werkmeister
|
|
2
|
+
|
|
3
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
4
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
5
|
+
copyright notice and this permission notice appear in all copies.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
8
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
9
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
10
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
11
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
12
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
|
|
13
|
+
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# m3api-rest
|
|
2
|
+
|
|
3
|
+
m3api-rest is an extension package for [m3api],
|
|
4
|
+
allowing you to interact with the MediaWiki REST API.
|
|
5
|
+
|
|
6
|
+
## Request functions
|
|
7
|
+
|
|
8
|
+
m3api-rest includes functions to make GET and POST requests that return JSON responses.
|
|
9
|
+
Other request methods and response content types will be added in future versions.
|
|
10
|
+
All functions start with the HTTP method name (in lowercase);
|
|
11
|
+
for GET functions, this is directly followed by the response type (e.g. `getJson`),
|
|
12
|
+
while other functions have the word “for” in between (e.g. `postForJson`)
|
|
13
|
+
to clarify that this is the response content type, not the request content type.
|
|
14
|
+
(The content type of the request body is specified by the type of the body passed into the function;
|
|
15
|
+
currently `postForJson` only supports `URLSearchParams` bodies,
|
|
16
|
+
but in future versions the same function will support multiple body types.)
|
|
17
|
+
|
|
18
|
+
### `getJson`
|
|
19
|
+
|
|
20
|
+
Make a GET request for some JSON data.
|
|
21
|
+
Usage examples:
|
|
22
|
+
|
|
23
|
+
```js
|
|
24
|
+
import Session from 'm3api';
|
|
25
|
+
import { getJson, path } from 'm3api-rest';
|
|
26
|
+
|
|
27
|
+
const session = new Session( 'en.wikipedia.org', {}, {
|
|
28
|
+
userAgent: 'm3api-rest-README-example',
|
|
29
|
+
} );
|
|
30
|
+
|
|
31
|
+
const title = 'Main page';
|
|
32
|
+
const page = await getJson( session, path`/v1/page/${ title }` );
|
|
33
|
+
console.log( page ); // { id: 217225, title: 'Main page', ... }
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
const searchResults = await getJson( session, '/v1/search/page', {
|
|
38
|
+
q: 'search query',
|
|
39
|
+
limit: 10,
|
|
40
|
+
} );
|
|
41
|
+
console.log( searchResults ); // { pages: [ { title: 'Web query', ... }, ... ] }
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
See also below for details on the different ways to specify parameters.
|
|
45
|
+
|
|
46
|
+
### `postForJson`
|
|
47
|
+
|
|
48
|
+
Make a POST request that will return some JSON data.
|
|
49
|
+
Usage example:
|
|
50
|
+
|
|
51
|
+
```js
|
|
52
|
+
const wikitext = '== ==\nThis is <span id=id>bad</span> <span id=id>wikitext</span>.';
|
|
53
|
+
const lints = await postForJson( session, '/v1/transform/wikitext/to/lint', new URLSearchParams( {
|
|
54
|
+
wikitext,
|
|
55
|
+
} ) );
|
|
56
|
+
console.log( lints ); // [ { type: 'empty-heading', ... }, { type: 'duplicate-ids', ... } ]
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
(Note that some endpoints, including `POST /v1/page`, cannot be used yet,
|
|
60
|
+
because m3api-rest can currently only send `application/x-www-form-urlencoded` request bodies
|
|
61
|
+
but those endpoints require `application/json`.
|
|
62
|
+
This will be fixed in a future version.)
|
|
63
|
+
|
|
64
|
+
## Specifying parameters
|
|
65
|
+
|
|
66
|
+
REST API endpoints have different kinds of parameters.
|
|
67
|
+
Many endpoints take parameters in the path, written like `/v1/page/{title}` (`title` is a parameter);
|
|
68
|
+
many endpoints take query parameters after the path, like `/v1/search/page?q=search` (`q` is a parameter);
|
|
69
|
+
and non-GET endpoints (POST etc.) take body parameters in several encodings.
|
|
70
|
+
m3api-rest supports several ways to specify these parameters.
|
|
71
|
+
|
|
72
|
+
### Path parameters
|
|
73
|
+
|
|
74
|
+
Path parameters can be specified using a tagged template literal (string template)
|
|
75
|
+
using the `path` function,
|
|
76
|
+
which will encode the parameter value if necessary.
|
|
77
|
+
|
|
78
|
+
```js
|
|
79
|
+
import { path } from 'm3api-rest';
|
|
80
|
+
|
|
81
|
+
const title = 'AC/DC';
|
|
82
|
+
const url = path`/v1/page/${ title }`; // /v1/page/AC%2FDC
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
To use it, take the endpoint URL and change `{parameters}` to `${substitutions}`
|
|
86
|
+
(or `${ substitutions }` with spaces, depending on your code style).
|
|
87
|
+
|
|
88
|
+
Alternatively, you can pass the original endpoint URL into the request function unmodified,
|
|
89
|
+
and the request function will pull the parameters out of the params passed into it.
|
|
90
|
+
For example:
|
|
91
|
+
|
|
92
|
+
```js
|
|
93
|
+
const page = await getJson( session, '/v1/page/{title}', {
|
|
94
|
+
// this object contains the params for the request
|
|
95
|
+
title: 'AC/DC', // this is a path parameter
|
|
96
|
+
redirect: true, // this is a query parameter, see below
|
|
97
|
+
} );
|
|
98
|
+
// makes a request to /v1/page/AC%2FDC?redirect=true
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Query parameters
|
|
102
|
+
|
|
103
|
+
The GET request functions, e.g. `getJson`, take an object with query parameters after the path:
|
|
104
|
+
|
|
105
|
+
```js
|
|
106
|
+
const searchResults = await getJson( session, '/v1/search/page', {
|
|
107
|
+
q: 'search query',
|
|
108
|
+
limit: 10,
|
|
109
|
+
} );
|
|
110
|
+
// makes a request to /v1/search/page?q=search%20query&limit=10
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
As mentioned above, you can also specify path parameters here.
|
|
114
|
+
|
|
115
|
+
Request functions for other methods don’t take an object with query parameters,
|
|
116
|
+
since they already take a request body.
|
|
117
|
+
Instead, if the endpoint still takes query parameters (which is uncommon, but possible),
|
|
118
|
+
you should pass them into the `path` as an object:
|
|
119
|
+
|
|
120
|
+
```js
|
|
121
|
+
const params = { fakeQueryParam: 'abc' };
|
|
122
|
+
await postForJson( path`/v0/fake/endpoint?${ params }`, new URLSearchParams( {
|
|
123
|
+
fakeBodyParam: 'xyz',
|
|
124
|
+
} ) );
|
|
125
|
+
// makes a request to /v0/fake/endpoint?fakeQueryParam=abc with fakeBodyParam=xyz in the body
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
This is also possible for GET requests, but you should probably prefer passing the query parameters separately there.
|
|
129
|
+
|
|
130
|
+
### Body parameters
|
|
131
|
+
|
|
132
|
+
The non-GET request functions, e.g. `postForJson`, take a value for the body after the path.
|
|
133
|
+
The type of the value encodes the content type with which the body will be sent.
|
|
134
|
+
Currently, the only supported type is `URLSearchParams`,
|
|
135
|
+
which will send the body as `application/x-www-form-urlencoded`;
|
|
136
|
+
support for other body types
|
|
137
|
+
(`FormData` for `multipart/form-data`, plain object for `application/json`)
|
|
138
|
+
will be added in a future version.
|
|
139
|
+
|
|
140
|
+
```js
|
|
141
|
+
const lints = await postForJson( session, '/v1/transform/wikitext/to/lint', new URLSearchParams( {
|
|
142
|
+
wikitext: 'some wikitext',
|
|
143
|
+
} ) );
|
|
144
|
+
/// makes a request to /v1/transform/wikitext/to/lint with wikitext=some%20wikitext
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
As mentioned above, you can also specify path parameters here.
|
|
148
|
+
|
|
149
|
+
## License
|
|
150
|
+
|
|
151
|
+
Published under the [ISC License][].
|
|
152
|
+
By contributing to this software,
|
|
153
|
+
you agree to publish your contribution under the same license.
|
|
154
|
+
|
|
155
|
+
[m3api]: https://www.npmjs.com/package/m3api
|
|
156
|
+
[ISC License]: https://spdx.org/licenses/ISC.html
|
package/index.js
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An Error representing an HTTP 5xx response from the REST API.
|
|
3
|
+
*/
|
|
4
|
+
export class RestApiServerError extends Error {
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {number} status The invalid status code received from the API.
|
|
8
|
+
* @param {string|Object} body The response body received from the API.
|
|
9
|
+
*/
|
|
10
|
+
constructor( status, body ) {
|
|
11
|
+
super( `REST API server error: ${ status }\n\n${ JSON.stringify( body ) }` );
|
|
12
|
+
|
|
13
|
+
if ( Error.captureStackTrace ) {
|
|
14
|
+
Error.captureStackTrace( this, RestApiServerError );
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
this.name = 'RestApiServerError';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The invalid status code received from the API.
|
|
21
|
+
*
|
|
22
|
+
* @member {number}
|
|
23
|
+
*/
|
|
24
|
+
this.status = status;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The body of the response.
|
|
28
|
+
*
|
|
29
|
+
* Depending on the response’s content type,
|
|
30
|
+
* this may be a string or a JSON-decoded object.
|
|
31
|
+
*
|
|
32
|
+
* @member {string|Object}
|
|
33
|
+
*/
|
|
34
|
+
this.body = body;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* An Error representing an HTTP 4xx response from the REST API.
|
|
41
|
+
*/
|
|
42
|
+
export class RestApiClientError extends Error {
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {number} status The invalid status code received from the API.
|
|
46
|
+
* @param {string|Object} body The response body received from the API.
|
|
47
|
+
*/
|
|
48
|
+
constructor( status, body ) {
|
|
49
|
+
super( `REST API client error: ${ status }\n\n${ JSON.stringify( body ) }` );
|
|
50
|
+
|
|
51
|
+
if ( Error.captureStackTrace ) {
|
|
52
|
+
Error.captureStackTrace( this, RestApiClientError );
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
this.name = 'RestApiClientError';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The invalid status code received from the API.
|
|
59
|
+
*
|
|
60
|
+
* @member {number}
|
|
61
|
+
*/
|
|
62
|
+
this.status = status;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The body of the response.
|
|
66
|
+
*
|
|
67
|
+
* Depending on the response’s content type,
|
|
68
|
+
* this may be a string or a JSON-decoded object.
|
|
69
|
+
*
|
|
70
|
+
* @member {string|Object}
|
|
71
|
+
*/
|
|
72
|
+
this.body = body;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* An Error representing an unexpected HTTP response status (1xx or 3xx) from the REST API.
|
|
79
|
+
* 3xx responses are unexpected because the JavaScript runtime should have followed the redirect;
|
|
80
|
+
* 1xx responses are unexpected because it should have awaited the non-informational response.
|
|
81
|
+
*/
|
|
82
|
+
export class UnexpectedResponseStatus extends Error {
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {number} status The unexpected status code received from the API.
|
|
86
|
+
* @param {string|Object} body The response body received from the API.
|
|
87
|
+
*/
|
|
88
|
+
constructor( status, body ) {
|
|
89
|
+
super( `Unexpected REST API response status: ${ status }\n\n${ JSON.stringify( body ) }` );
|
|
90
|
+
|
|
91
|
+
if ( Error.captureStackTrace ) {
|
|
92
|
+
Error.captureStackTrace( this, UnexpectedResponseStatus );
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
this.name = 'UnexpectedResponseStatus';
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The unexpected status code received from the API.
|
|
99
|
+
*
|
|
100
|
+
* @member {number}
|
|
101
|
+
*/
|
|
102
|
+
this.status = status;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The body of the response.
|
|
106
|
+
*
|
|
107
|
+
* Depending on the response’s content type,
|
|
108
|
+
* this may be a string or a JSON-decoded object.
|
|
109
|
+
*
|
|
110
|
+
* @member {string|Object}
|
|
111
|
+
*/
|
|
112
|
+
this.body = body;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* An Error representing an invalid body in the REST API response.
|
|
119
|
+
*/
|
|
120
|
+
export class InvalidResponseBody extends Error {
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @param {*} body The invalid response body received from the API.
|
|
124
|
+
*/
|
|
125
|
+
constructor( body ) {
|
|
126
|
+
super( `Invalid REST API response body: ${ JSON.stringify( body ) }` );
|
|
127
|
+
|
|
128
|
+
if ( Error.captureStackTrace ) {
|
|
129
|
+
Error.captureStackTrace( this, InvalidResponseBody );
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
this.name = 'InvalidResponseBody';
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The body of the response.
|
|
136
|
+
* Objects and arrays are expected response bodies,
|
|
137
|
+
* so an unexpected body is probably some kind of primitive value,
|
|
138
|
+
* such as a string, number or boolean.
|
|
139
|
+
*
|
|
140
|
+
* @member {*}
|
|
141
|
+
*/
|
|
142
|
+
this.body = body;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* An Error thrown if {@link getResponseStatus} is called with an unknown response.
|
|
149
|
+
*
|
|
150
|
+
* {@link getResponseStatus} must be called with a value that was previously returned
|
|
151
|
+
* by one of the request functions ({@link getJson} etc.).
|
|
152
|
+
* If this error is thrown, then {@link getResponseStatus} was called with some other value,
|
|
153
|
+
* and the response status cannot be determined.
|
|
154
|
+
* This may be the result of calling the function incorrectly.
|
|
155
|
+
*
|
|
156
|
+
* The following example shows how {@link getResponseStatus} can be used correctly:
|
|
157
|
+
* ```
|
|
158
|
+
* const title = 'Main Page';
|
|
159
|
+
* const page = await getJson( session, path`/v1/page/${ title }/bare` );
|
|
160
|
+
* const status = getResponseStatus( page );
|
|
161
|
+
* const { id, latest } = page;
|
|
162
|
+
* ```
|
|
163
|
+
*
|
|
164
|
+
* For comparison, the following usage of {@link getResponseStatus} is **incorrect**:
|
|
165
|
+
* ```
|
|
166
|
+
* const title = 'Main Page';
|
|
167
|
+
* const { id, latest } = await getJson( session, path`/v1/page/${ title }/bare` );
|
|
168
|
+
* const status = getResponseStatus( { id, latest } ); // this does not work
|
|
169
|
+
* ```
|
|
170
|
+
*/
|
|
171
|
+
export class UnknownResponseError extends Error {
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* @param {*} response The unknown response passed into {@link getResponseStatus}.
|
|
175
|
+
*/
|
|
176
|
+
constructor( response ) {
|
|
177
|
+
super( `Unknown REST API response: ${ JSON.stringify( response ) }` );
|
|
178
|
+
|
|
179
|
+
if ( Error.captureStackTrace ) {
|
|
180
|
+
Error.captureStackTrace( this, UnknownResponseError );
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
this.name = 'UnknownResponseError';
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The unknown response passed into {@link getResponseStatus}.
|
|
187
|
+
*
|
|
188
|
+
* @member {*}
|
|
189
|
+
*/
|
|
190
|
+
this.response = response;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* An Error thrown if a request path contains an unsubstituted parameter.
|
|
197
|
+
*
|
|
198
|
+
* For example, if {@link getJson} is called with the path `/v1/page/{title}`,
|
|
199
|
+
* the `title` must be specified in the params, like this:
|
|
200
|
+
* ```
|
|
201
|
+
* const title = 'Main Page';
|
|
202
|
+
* const page = await getJson( session, '/v1/page/{title}', { title } );
|
|
203
|
+
* ```
|
|
204
|
+
*
|
|
205
|
+
* Alternatively, you can directly interpolate the title using {@link path}:
|
|
206
|
+
* ```
|
|
207
|
+
* const title = 'Main Page';
|
|
208
|
+
* const page = await getJson( session, path`/v1/page/${ title }` );
|
|
209
|
+
* ```
|
|
210
|
+
*
|
|
211
|
+
* But the following is invalid and will result in this error being thrown:
|
|
212
|
+
* ```
|
|
213
|
+
* const page = await getJson( session, '/v1/page/{title}' ); // which title?
|
|
214
|
+
* // ^ throws InvalidPathParams
|
|
215
|
+
* ```
|
|
216
|
+
*
|
|
217
|
+
* (Another, less common condition under which this error may be thrown
|
|
218
|
+
* is that a path parameter was assigned multiple values;
|
|
219
|
+
* this is possible with functions like {@link postForJson},
|
|
220
|
+
* where the params of type {@link URLSearchParams} can contain
|
|
221
|
+
* multiple values for the same name.)
|
|
222
|
+
*/
|
|
223
|
+
export class InvalidPathParams extends Error {
|
|
224
|
+
|
|
225
|
+
constructor( message, path, paramName, params ) {
|
|
226
|
+
super( message );
|
|
227
|
+
|
|
228
|
+
if ( Error.captureStackTrace ) {
|
|
229
|
+
Error.captureStackTrace( this, InvalidPathParams );
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
this.name = 'InvalidPathParams';
|
|
233
|
+
|
|
234
|
+
this.path = path;
|
|
235
|
+
|
|
236
|
+
this.paramName = paramName;
|
|
237
|
+
|
|
238
|
+
this.params = params;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Check the status code of the response and potentially throw an error based on it.
|
|
245
|
+
*
|
|
246
|
+
* @private
|
|
247
|
+
* @param {Object} internalResponse
|
|
248
|
+
*/
|
|
249
|
+
function checkResponseStatus( internalResponse ) {
|
|
250
|
+
const { status, body } = internalResponse;
|
|
251
|
+
if ( !Number.isInteger( status ) || status < 100 || status > 599 ) {
|
|
252
|
+
// invalid status: RFC 9110 section 15 says treat it like 5xx
|
|
253
|
+
throw new RestApiServerError( status, body );
|
|
254
|
+
}
|
|
255
|
+
if ( status >= 500 ) {
|
|
256
|
+
throw new RestApiServerError( status, body );
|
|
257
|
+
}
|
|
258
|
+
if ( status >= 400 ) {
|
|
259
|
+
throw new RestApiClientError( status, body );
|
|
260
|
+
}
|
|
261
|
+
if ( status < 200 || status >= 300 ) {
|
|
262
|
+
throw new UnexpectedResponseStatus( status, body );
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const responseStatuses = new WeakMap();
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Get the HTTP status code for this response.
|
|
270
|
+
*
|
|
271
|
+
* @param {Object|Array} response A response object returned by one of the request functions
|
|
272
|
+
* ({@link getJson} etc.). Note that it must be exactly the object returned by the function
|
|
273
|
+
* (compared by identity, i.e. `===`), not a serialization of it or anything similar.
|
|
274
|
+
* @return {number} The HTTP status code, i.e. an integer between 100 and 599.
|
|
275
|
+
* (And for a successful response, really only between 200 and 299.)
|
|
276
|
+
*/
|
|
277
|
+
export function getResponseStatus( response ) {
|
|
278
|
+
const status = responseStatuses.get( response );
|
|
279
|
+
if ( status === undefined ) {
|
|
280
|
+
throw new UnknownResponseError( response );
|
|
281
|
+
}
|
|
282
|
+
return status;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Get the body of the response and check that it’s valid JSON.
|
|
287
|
+
*
|
|
288
|
+
* @private
|
|
289
|
+
* @param {Object} internalResponse
|
|
290
|
+
* @return {Object|Array}
|
|
291
|
+
*/
|
|
292
|
+
function getResponseJson( internalResponse ) {
|
|
293
|
+
const { status, body } = internalResponse;
|
|
294
|
+
if ( typeof body === 'object' && body !== null ) {
|
|
295
|
+
responseStatuses.set( body, status );
|
|
296
|
+
return body;
|
|
297
|
+
} else {
|
|
298
|
+
throw new InvalidResponseBody( body );
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Encode a path for a REST API endpoint.
|
|
304
|
+
*
|
|
305
|
+
* This function should be used as a tag for a tagged template literal (template string).
|
|
306
|
+
* Usage example:
|
|
307
|
+
* ```
|
|
308
|
+
* const title = 'AC/DC';
|
|
309
|
+
* const json = await getJson( session, path`/v1/page/${ title }` );
|
|
310
|
+
* // makes a request to /v1/page/AC%2FDC
|
|
311
|
+
* ```
|
|
312
|
+
*
|
|
313
|
+
* Each parameter in the template can either be a string or an object of query params,
|
|
314
|
+
* for example:
|
|
315
|
+
* ```
|
|
316
|
+
* const id = 376020677;
|
|
317
|
+
* const params = { stash: false, flavor: 'view' };
|
|
318
|
+
* const json = await getJson( session, path`/v1/revision/${ id }/html?${ params }` );
|
|
319
|
+
* ```
|
|
320
|
+
*
|
|
321
|
+
* Alternatively, query params for GET requests can be specified separately:
|
|
322
|
+
* ```
|
|
323
|
+
* const id = 376020677;
|
|
324
|
+
* const json = await getJson( session, path`/v1/revision/${ id }/html`, {
|
|
325
|
+
* stash: false,
|
|
326
|
+
* flavor: 'view',
|
|
327
|
+
* } );
|
|
328
|
+
* ```
|
|
329
|
+
*
|
|
330
|
+
* (Calling this function like a regular function is not very useful,
|
|
331
|
+
* so you can ignore the parameters documented below.)
|
|
332
|
+
*
|
|
333
|
+
* @param {string|object[]} strings
|
|
334
|
+
* @param {Array} values
|
|
335
|
+
* @return {string}
|
|
336
|
+
*/
|
|
337
|
+
export function path( strings, ...values ) {
|
|
338
|
+
return String.raw( { raw: strings }, ...values.map( ( value ) => {
|
|
339
|
+
if ( typeof value === 'object' ) {
|
|
340
|
+
return new URLSearchParams( value );
|
|
341
|
+
}
|
|
342
|
+
return encodeURIComponent( value );
|
|
343
|
+
} ) );
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Substitute template expressions in the path,
|
|
348
|
+
* taking values from the given params.
|
|
349
|
+
*
|
|
350
|
+
* @param {string} path The path with optional `{param}` expressions.
|
|
351
|
+
* @param {Object|URLSearchParams} params The input params. Not modified.
|
|
352
|
+
* @return {Object} The potentially modified path and params.
|
|
353
|
+
*/
|
|
354
|
+
function substitutePathParams( path, params ) {
|
|
355
|
+
let copiedParams = null;
|
|
356
|
+
const substitutedPath = path.replace( /\{[^{}]+\}/g, ( match ) => {
|
|
357
|
+
const paramName = match.slice( 1, -1 );
|
|
358
|
+
if ( params instanceof URLSearchParams ) {
|
|
359
|
+
if ( copiedParams === null ) {
|
|
360
|
+
copiedParams = new URLSearchParams( params );
|
|
361
|
+
}
|
|
362
|
+
const values = copiedParams.getAll( paramName );
|
|
363
|
+
if ( values.length !== 1 ) {
|
|
364
|
+
throw new InvalidPathParams(
|
|
365
|
+
values.length === 0 ?
|
|
366
|
+
`Unspecified path param ${ match }` :
|
|
367
|
+
`Ambiguous path param ${ match }`,
|
|
368
|
+
path,
|
|
369
|
+
paramName,
|
|
370
|
+
params,
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
copiedParams.delete( paramName );
|
|
374
|
+
return encodeURIComponent( values[ 0 ] );
|
|
375
|
+
} else {
|
|
376
|
+
if ( copiedParams === null ) {
|
|
377
|
+
copiedParams = { ...params };
|
|
378
|
+
}
|
|
379
|
+
const value = copiedParams[ paramName ];
|
|
380
|
+
if ( value === undefined ) {
|
|
381
|
+
throw new InvalidPathParams(
|
|
382
|
+
`Unspecified path param ${ match }`,
|
|
383
|
+
path,
|
|
384
|
+
paramName,
|
|
385
|
+
params,
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
delete copiedParams[ paramName ];
|
|
389
|
+
return encodeURIComponent( value );
|
|
390
|
+
}
|
|
391
|
+
} );
|
|
392
|
+
|
|
393
|
+
return { path: substitutedPath, params: copiedParams || params };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Split a given URL for the m3api internal interface.
|
|
398
|
+
*
|
|
399
|
+
* @param {string} url
|
|
400
|
+
* @return {Object}
|
|
401
|
+
*/
|
|
402
|
+
function splitUrlForInternalInterface( url ) {
|
|
403
|
+
url = new URL( url );
|
|
404
|
+
const urlParams = {};
|
|
405
|
+
for ( const [ key, value ] of url.searchParams.entries() ) {
|
|
406
|
+
urlParams[ key ] = value;
|
|
407
|
+
}
|
|
408
|
+
url.search = '';
|
|
409
|
+
return { url: String( url ), urlParams };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Make a GET request to a REST API endpoint and return the JSON-decoded body.
|
|
414
|
+
*
|
|
415
|
+
* @param {Session} session The m3api session to use for this request.
|
|
416
|
+
* @param {string} path The resource path, e.g. `/v1/search`.
|
|
417
|
+
* Does not include the domain, script path, or `rest.php` endpoint.
|
|
418
|
+
* Use the {@link path} tag function to build the path.
|
|
419
|
+
* @param {Object} [params] Parameters for the request URL.
|
|
420
|
+
* This may include both parameters for the path and query parameters.
|
|
421
|
+
* @param {Options} [options] Request options.
|
|
422
|
+
* @return {Object|Array} The body of the API response, JSON-decoded.
|
|
423
|
+
*/
|
|
424
|
+
export async function getJson( session, path, params, options = {} ) {
|
|
425
|
+
( { path, params } = substitutePathParams( path, params ) );
|
|
426
|
+
const restUrl = session.apiUrl.replace( /api\.php$/, 'rest.php' );
|
|
427
|
+
const { url, urlParams } = splitUrlForInternalInterface( restUrl + path );
|
|
428
|
+
params = { ...urlParams, ...params };
|
|
429
|
+
const headers = {
|
|
430
|
+
accept: 'application/json',
|
|
431
|
+
...session.getRequestHeaders( options ),
|
|
432
|
+
};
|
|
433
|
+
const internalResponse = await session.internalGet( url, params, headers );
|
|
434
|
+
checkResponseStatus( internalResponse );
|
|
435
|
+
return getResponseJson( internalResponse );
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Make a POST request to a REST API endpoint and return the JSON-decoded body.
|
|
440
|
+
*
|
|
441
|
+
* @param {Session} session The m3api session to use for this request.
|
|
442
|
+
* @param {string} path The resourcee path, e.g. `/v1/page`.
|
|
443
|
+
* Does not include the domain, script path, or `rest.php` endpoint.
|
|
444
|
+
* Use the {@link path} tag function to build the path.
|
|
445
|
+
* @param {URLSearchParams} params The request body.
|
|
446
|
+
* Will be sent using the `application/x-www-form-urlencoded` content type.
|
|
447
|
+
* (Future versions of this library will support additional request body content types,
|
|
448
|
+
* but that requires changes to m3api first.)
|
|
449
|
+
* You may also include parameters for the path here.
|
|
450
|
+
* @param {Options} [options] Request options.
|
|
451
|
+
* @return {Object|Array} The body of the API response, JSON-decoded.
|
|
452
|
+
*/
|
|
453
|
+
export async function postForJson( session, path, params, options = {} ) {
|
|
454
|
+
( { path, params } = substitutePathParams( path, params ) );
|
|
455
|
+
const restUrl = session.apiUrl.replace( /api\.php$/, 'rest.php' );
|
|
456
|
+
const { url, urlParams } = splitUrlForInternalInterface( restUrl + path );
|
|
457
|
+
const bodyParams = {};
|
|
458
|
+
for ( const [ key, value ] of params ) {
|
|
459
|
+
if ( Object.prototype.hasOwnProperty.call( bodyParams, key ) ) {
|
|
460
|
+
throw new Error( `Duplicate param name not yet supported: ${ key }` );
|
|
461
|
+
}
|
|
462
|
+
bodyParams[ key ] = value;
|
|
463
|
+
}
|
|
464
|
+
const headers = {
|
|
465
|
+
// accept: 'application/json', // skip this for now due to T412610
|
|
466
|
+
...session.getRequestHeaders( options ),
|
|
467
|
+
};
|
|
468
|
+
const internalResponse = await session.internalPost( url, urlParams, bodyParams, headers );
|
|
469
|
+
checkResponseStatus( internalResponse );
|
|
470
|
+
return getResponseJson( internalResponse );
|
|
471
|
+
}
|
package/jsdoc/conf.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"source": {
|
|
3
|
+
"include": [
|
|
4
|
+
"."
|
|
5
|
+
],
|
|
6
|
+
"exclude": [
|
|
7
|
+
"test"
|
|
8
|
+
]
|
|
9
|
+
},
|
|
10
|
+
"templates": {
|
|
11
|
+
"default": {
|
|
12
|
+
"layoutFile": "jsdoc/layout.tmpl",
|
|
13
|
+
"staticFiles": {
|
|
14
|
+
"include": [
|
|
15
|
+
"jsdoc/version-warning.css"
|
|
16
|
+
]
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"opts": {
|
|
21
|
+
"destination": "doc",
|
|
22
|
+
"readme": "README.md"
|
|
23
|
+
},
|
|
24
|
+
"plugins": [
|
|
25
|
+
"plugins/markdown"
|
|
26
|
+
]
|
|
27
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>m3api-rest: <?js= title ?></title>
|
|
6
|
+
|
|
7
|
+
<script defer src="scripts/prettify/prettify.js"></script>
|
|
8
|
+
<script defer src="scripts/prettify/lang-css.js"></script>
|
|
9
|
+
<link type="text/css" rel="stylesheet" href="styles/prettify-tomorrow.css">
|
|
10
|
+
<link type="text/css" rel="stylesheet" href="styles/jsdoc-default.css">
|
|
11
|
+
<link type="text/css" rel="stylesheet" href="version-warning.css">
|
|
12
|
+
</head>
|
|
13
|
+
|
|
14
|
+
<body>
|
|
15
|
+
|
|
16
|
+
<div id="main">
|
|
17
|
+
|
|
18
|
+
<!-- VERSION-WARNING -->
|
|
19
|
+
|
|
20
|
+
<h1 class="page-title"><?js= title ?></h1>
|
|
21
|
+
|
|
22
|
+
<?js= content ?>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<nav>
|
|
26
|
+
<?js= this.nav ?>
|
|
27
|
+
</nav>
|
|
28
|
+
|
|
29
|
+
<br class="clear">
|
|
30
|
+
|
|
31
|
+
<footer>
|
|
32
|
+
Documentation generated by <a href="https://github.com/jsdoc/jsdoc">JSDoc <?js= env.version.number ?></a>
|
|
33
|
+
</footer>
|
|
34
|
+
|
|
35
|
+
<script>document.addEventListener('DOMContentLoaded', function () { prettyPrint(); });</script>
|
|
36
|
+
<script async src="scripts/linenumber.js"></script>
|
|
37
|
+
</body>
|
|
38
|
+
</html>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
body {
|
|
2
|
+
--body-margin: 8px; /* copied from Firefox */
|
|
3
|
+
margin: var( --body-margin );
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
.version-warning {
|
|
7
|
+
--version-warning-margin-y: 2px;
|
|
8
|
+
--version-warning-margin-x: 24px; /* matching other doc elements */
|
|
9
|
+
top: calc(var(--body-margin) + var(--version-warning-margin-y));
|
|
10
|
+
position: sticky;
|
|
11
|
+
padding: 0.5em 3em;
|
|
12
|
+
margin: var(--version-warning-margin-y) var(--version-warning-margin-x);
|
|
13
|
+
border-radius: 4px;
|
|
14
|
+
text-align: center;
|
|
15
|
+
font-size: 16px; /* absolute px to match other doc elements */
|
|
16
|
+
color: #fff; /* Base100 */
|
|
17
|
+
background: repeating-linear-gradient(
|
|
18
|
+
135deg,
|
|
19
|
+
#36c 0px 56px, /* Accent50 */
|
|
20
|
+
#2a4b8d 56px 112px /* Accent30 */
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.version-warning a {
|
|
25
|
+
color: #fff; /* Base100 */
|
|
26
|
+
text-decoration: underline;
|
|
27
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "m3api-rest",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "m3api extension package to interact with the MediaWiki REST API.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "npm-run-all test:*",
|
|
9
|
+
"test:lint": "eslint .",
|
|
10
|
+
"test:unit": "mocha test/unit/",
|
|
11
|
+
"test:integration": "mocha test/integration/",
|
|
12
|
+
"doc": "jsdoc -c jsdoc/conf.json"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://gitlab.wikimedia.org/repos/m3api/m3api-rest#m3api-rest",
|
|
15
|
+
"bugs": "https://phabricator.wikimedia.org/tag/m3api/",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://gitlab.wikimedia.org/repos/m3api/m3api-rest.git"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"mediawiki"
|
|
22
|
+
],
|
|
23
|
+
"author": "Lucas Werkmeister <mail@lucaswerkmeister.de>",
|
|
24
|
+
"license": "ISC",
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=18.2.0"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"m3api": "~1.0.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"chai": "^6.0.1",
|
|
33
|
+
"chai-as-promised": "^8.0.1",
|
|
34
|
+
"chai-string": "^2.0.0",
|
|
35
|
+
"eslint": "^8.44.0",
|
|
36
|
+
"eslint-config-wikimedia": "^0.31.0",
|
|
37
|
+
"eslint-plugin-chai-friendly": "^1.0.1",
|
|
38
|
+
"jsdoc": "^4.0.3",
|
|
39
|
+
"m3api": "~1.0.0",
|
|
40
|
+
"mocha": "^11.1.0",
|
|
41
|
+
"npm-run-all": "^4.1.5"
|
|
42
|
+
},
|
|
43
|
+
"mocha": {
|
|
44
|
+
"recursive": true
|
|
45
|
+
}
|
|
46
|
+
}
|