m3api-rest 0.1.0 → 0.1.1

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.
Files changed (3) hide show
  1. package/CHANGES.md +10 -0
  2. package/index.js +129 -52
  3. package/package.json +3 -3
package/CHANGES.md CHANGED
@@ -5,6 +5,16 @@ This file records the changes in each m3api-rest release.
5
5
  The annotated tag (and GitLab release) for each version also lists the changes,
6
6
  but this file may sometimes contain later improvements (e.g. typo fixes).
7
7
 
8
+ ## v0.1.1 (2026-04-05)
9
+
10
+ - Updated the library for the new network interface of m3api v1.1.0,
11
+ so that it can be used together with that version.
12
+ (This also unlocks many new features in m3api-rest,
13
+ which will be implemented in a subsequent release.)
14
+ - Introduced a new error type, `IncompatibleResponseType`,
15
+ thrown if the server responds with an incompatible response type.
16
+ - Updated dependencies.
17
+
8
18
  ## v0.1.0 (2026-01-01)
9
19
 
10
20
  Initial release, including:
package/index.js CHANGED
@@ -114,6 +114,58 @@ export class UnexpectedResponseStatus extends Error {
114
114
 
115
115
  }
116
116
 
117
+ /**
118
+ * An error representing a HTTP response with a content type incompatible with that requested.
119
+ * For example, if the API responded with HTML despite JSON having been requested.
120
+ * Note that, depending on server behavior, this condition may also be represented
121
+ * by a {@link RestApiClientError} with the status 406 Not Acceptable;
122
+ * {@link IncompatibleResponseType} is thrown by m3api-rest if the server responded
123
+ * with a successful status that nevertheless does not match the requested type.
124
+ */
125
+ export class IncompatibleResponseType extends Error {
126
+
127
+ /**
128
+ * @param {string} expectedType The expected MIME type.
129
+ * @param {string} actualType The actual Content-Type response header.
130
+ * @param {*} body The response body received from the API.
131
+ */
132
+ constructor( expectedType, actualType, body ) {
133
+ super( `Incompatible REST API response type: expected ${ expectedType }, got ${ actualType }` );
134
+
135
+ if ( Error.captureStackTrace ) {
136
+ Error.captureStackTrace( this, IncompatibleResponseType );
137
+ }
138
+
139
+ this.name = 'IncompatibleResponseType';
140
+
141
+ /**
142
+ * The MIME type expected by m3api-rest based on the method,
143
+ * e.g. 'application/json' for {@link getJson} and related functions.
144
+ *
145
+ * @member {string}
146
+ */
147
+ this.expectedType = expectedType;
148
+
149
+ /**
150
+ * The actual Content-Type header returned by the server,
151
+ * e.g. 'text/html; charset=utf-8; profile="https://www.mediawiki.org/wiki/Specs/HTML/2.8.0"'.
152
+ *
153
+ * @member {string}
154
+ */
155
+ this.actualType = actualType;
156
+
157
+ /**
158
+ * The response body.
159
+ * The type of this member will depend on the Content-Type returned by the server
160
+ * and on whether m3api-rest is able to decode that content type or not.
161
+ *
162
+ * @member {*}
163
+ */
164
+ this.body = body;
165
+ }
166
+
167
+ }
168
+
117
169
  /**
118
170
  * An Error representing an invalid body in the REST API response.
119
171
  */
@@ -241,25 +293,55 @@ export class InvalidPathParams extends Error {
241
293
  }
242
294
 
243
295
  /**
244
- * Check the status code of the response and potentially throw an error based on it.
296
+ * Determine whether this response contains JSON
297
+ * according to its headers.
245
298
  *
246
299
  * @private
247
- * @param {Object} internalResponse
300
+ * @param {Response} response
301
+ * @return {boolean}
248
302
  */
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 );
303
+ function isResponseJson( response ) {
304
+ const contentType = response.headers.get( 'Content-Type' );
305
+ const [ mimeType ] = contentType.split( ';', 1 ); // split off ;charset=utf-8 and suchlike
306
+ return mimeType === 'application/json' ||
307
+ ( mimeType.startsWith( 'application/' ) && mimeType.endsWith( '+json' ) );
308
+ // this accepts some technically invalid Content-Type headers
309
+ // (we don’t check if the part before +json is a valid subtype / token,
310
+ // cf. https://httpwg.org/specs/rfc9110.html#media.type)
311
+ // because there’s no reason to reject them
312
+ }
313
+
314
+ /**
315
+ * Get a version of the body of the response to put in an error.
316
+ *
317
+ * @private
318
+ * @param {Response} response
319
+ * @return {string|Object}
320
+ */
321
+ async function getResponseBodyForError( response ) {
322
+ if ( isResponseJson( response ) ) {
323
+ return await response.json();
324
+ } else {
325
+ return await response.text();
254
326
  }
327
+ }
328
+
329
+ /**
330
+ * Check the status code of the response and potentially throw an error based on it.
331
+ *
332
+ * @private
333
+ * @param {Response} response
334
+ */
335
+ async function checkResponseStatus( response ) {
336
+ const { status } = response;
255
337
  if ( status >= 500 ) {
256
- throw new RestApiServerError( status, body );
338
+ throw new RestApiServerError( status, await getResponseBodyForError( response ) );
257
339
  }
258
340
  if ( status >= 400 ) {
259
- throw new RestApiClientError( status, body );
341
+ throw new RestApiClientError( status, await getResponseBodyForError( response ) );
260
342
  }
261
- if ( status < 200 || status >= 300 ) {
262
- throw new UnexpectedResponseStatus( status, body );
343
+ if ( status >= 300 ) {
344
+ throw new UnexpectedResponseStatus( status, await getResponseBodyForError( response ) );
263
345
  }
264
346
  }
265
347
 
@@ -271,7 +353,7 @@ const responseStatuses = new WeakMap();
271
353
  * @param {Object|Array} response A response object returned by one of the request functions
272
354
  * ({@link getJson} etc.). Note that it must be exactly the object returned by the function
273
355
  * (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.
356
+ * @return {number} The HTTP status code, i.e. an integer between 200 and 599.
275
357
  * (And for a successful response, really only between 200 and 299.)
276
358
  */
277
359
  export function getResponseStatus( response ) {
@@ -286,16 +368,24 @@ export function getResponseStatus( response ) {
286
368
  * Get the body of the response and check that it’s valid JSON.
287
369
  *
288
370
  * @private
289
- * @param {Object} internalResponse
371
+ * @param {Response} response
290
372
  * @return {Object|Array}
291
373
  */
292
- function getResponseJson( internalResponse ) {
293
- const { status, body } = internalResponse;
294
- if ( typeof body === 'object' && body !== null ) {
295
- responseStatuses.set( body, status );
296
- return body;
374
+ async function getResponseJson( response ) {
375
+ if ( isResponseJson( response ) ) {
376
+ const body = await response.json();
377
+ if ( typeof body === 'object' && body !== null ) {
378
+ responseStatuses.set( body, response.status );
379
+ return body;
380
+ } else {
381
+ throw new InvalidResponseBody( body );
382
+ }
297
383
  } else {
298
- throw new InvalidResponseBody( body );
384
+ throw new IncompatibleResponseType(
385
+ 'application/json',
386
+ response.headers.get( 'Content-Type' ),
387
+ await response.text(),
388
+ );
299
389
  }
300
390
  }
301
391
 
@@ -347,6 +437,7 @@ export function path( strings, ...values ) {
347
437
  * Substitute template expressions in the path,
348
438
  * taking values from the given params.
349
439
  *
440
+ * @private
350
441
  * @param {string} path The path with optional `{param}` expressions.
351
442
  * @param {Object|URLSearchParams} params The input params. Not modified.
352
443
  * @return {Object} The potentially modified path and params.
@@ -393,22 +484,6 @@ function substitutePathParams( path, params ) {
393
484
  return { path: substitutedPath, params: copiedParams || params };
394
485
  }
395
486
 
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
487
  /**
413
488
  * Make a GET request to a REST API endpoint and return the JSON-decoded body.
414
489
  *
@@ -424,22 +499,27 @@ function splitUrlForInternalInterface( url ) {
424
499
  export async function getJson( session, path, params, options = {} ) {
425
500
  ( { path, params } = substitutePathParams( path, params ) );
426
501
  const restUrl = session.apiUrl.replace( /api\.php$/, 'rest.php' );
427
- const { url, urlParams } = splitUrlForInternalInterface( restUrl + path );
428
- params = { ...urlParams, ...params };
502
+ const url = new URL( restUrl + path );
503
+ for ( const [ name, value ] of Object.entries( params || {} ) ) {
504
+ url.searchParams.append( name, value );
505
+ }
429
506
  const headers = {
430
507
  accept: 'application/json',
431
508
  ...session.getRequestHeaders( options ),
432
509
  };
433
- const internalResponse = await session.internalGet( url, params, headers );
434
- checkResponseStatus( internalResponse );
435
- return getResponseJson( internalResponse );
510
+ const response = await session.fetch( url, {
511
+ method: 'GET',
512
+ headers,
513
+ } );
514
+ await checkResponseStatus( response );
515
+ return await getResponseJson( response );
436
516
  }
437
517
 
438
518
  /**
439
519
  * Make a POST request to a REST API endpoint and return the JSON-decoded body.
440
520
  *
441
521
  * @param {Session} session The m3api session to use for this request.
442
- * @param {string} path The resourcee path, e.g. `/v1/page`.
522
+ * @param {string} path The resource path, e.g. `/v1/page`.
443
523
  * Does not include the domain, script path, or `rest.php` endpoint.
444
524
  * Use the {@link path} tag function to build the path.
445
525
  * @param {URLSearchParams} params The request body.
@@ -453,19 +533,16 @@ export async function getJson( session, path, params, options = {} ) {
453
533
  export async function postForJson( session, path, params, options = {} ) {
454
534
  ( { path, params } = substitutePathParams( path, params ) );
455
535
  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
- }
536
+ const url = new URL( restUrl + path );
464
537
  const headers = {
465
538
  // accept: 'application/json', // skip this for now due to T412610
466
539
  ...session.getRequestHeaders( options ),
467
540
  };
468
- const internalResponse = await session.internalPost( url, urlParams, bodyParams, headers );
469
- checkResponseStatus( internalResponse );
470
- return getResponseJson( internalResponse );
541
+ const response = await session.fetch( url, {
542
+ method: 'POST',
543
+ headers,
544
+ body: params,
545
+ } );
546
+ await checkResponseStatus( response );
547
+ return await getResponseJson( response );
471
548
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "m3api-rest",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "m3api extension package to interact with the MediaWiki REST API.",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -26,7 +26,7 @@
26
26
  "node": ">=18.2.0"
27
27
  },
28
28
  "peerDependencies": {
29
- "m3api": "~1.0.0"
29
+ "m3api": "~1.1.0"
30
30
  },
31
31
  "devDependencies": {
32
32
  "chai": "^6.0.1",
@@ -36,7 +36,7 @@
36
36
  "eslint-config-wikimedia": "^0.31.0",
37
37
  "eslint-plugin-chai-friendly": "^1.0.1",
38
38
  "jsdoc": "^4.0.3",
39
- "m3api": "~1.0.0",
39
+ "m3api": "~1.1.0",
40
40
  "mocha": "^11.1.0",
41
41
  "npm-run-all": "^4.1.5"
42
42
  },