Move REST search client out of WVUI into Vector

Bug: T288686
Depends-on: I4afc8c38dc9c51d55b46b766a1417b1266963482
Change-Id: Iac6023bb6edca5c8dddc3bfd362db727b2534946
This commit is contained in:
bwang 2022-02-02 15:17:56 -06:00
parent 7084f9a9df
commit 3e92800bd6
9 changed files with 461 additions and 14 deletions

View File

@ -2,3 +2,4 @@
// @ts-nocheck
var mockMediaWiki = require( '@wikimedia/mw-node-qunit/src/mockMediaWiki.js' );
global.mw = mockMediaWiki();
global.$ = require('jquery');

View File

@ -19,6 +19,7 @@
"linkMap": {
"\"addEventListener\"": "https://developer.mozilla.org/docs/Web/API/EventTarget/addEventListener",
"jQuery": "https://api.jquery.com",
"AbortSignal": "https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal",
"Document": "https://developer.mozilla.org/docs/Web/API/Document",
"Element": "https://developer.mozilla.org/docs/Web/API/Element",
"Event": "https://developer.mozilla.org/docs/Web/API/Event",
@ -31,11 +32,11 @@
"HTMLInputElement": "https://developer.mozilla.org/docs/Web/API/HTMLInputElement",
"\"removeEventListener\"": "https://developer.mozilla.org/docs/Web/API/EventTarget/removeEventListener",
"Window": "https://developer.mozilla.org/docs/Web/API/Window",
"CheckboxHack": "https://doc.wikimedia.org/mediawiki-core/master/js",
"MW": "https://doc.wikimedia.org/mediawiki-core/master/js/#!/api/mw",
"MediaWikiPageReadyModule": "https://doc.wikimedia.org/mediawiki-core/master/js/#!/api/mw.plugin.page.ready",
"MW": "https://doc.wikimedia.org/mediawiki-core/master/js/#!/api/mw",
"MwMap": "https://doc.wikimedia.org/mediawiki-core/master/js/#!/api/mw.Map",
"RequestInit": "https://developer.mozilla.org/en-US/docs/Web/API/Request/Request",
"JQueryStatic": "https://api.jquery.com",
"VectorResourceLoaderVirtualConfig": "#",
"void": "#",

140
package-lock.json generated
View File

@ -19,8 +19,9 @@
"eslint-config-wikimedia": "0.20.0",
"grunt-banana-checker": "0.9.0",
"jest": "26.4.2",
"jest-fetch-mock": "3.0.3",
"jsdoc": "3.6.7",
"jsdoc-wmf-theme": "0.0.3",
"jsdoc-wmf-theme": "0.0.5",
"less": "3.8.1",
"less-loader": "4.1.0",
"mustache": "3.0.1",
@ -7607,6 +7608,57 @@
"react": "^0.14.0 || ^15.0.0 || ^16.0.0"
}
},
"node_modules/cross-fetch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz",
"integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==",
"dev": true,
"dependencies": {
"node-fetch": "2.6.7"
}
},
"node_modules/cross-fetch/node_modules/node-fetch": {
"version": "2.6.7",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz",
"integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==",
"dev": true,
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/cross-fetch/node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=",
"dev": true
},
"node_modules/cross-fetch/node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=",
"dev": true
},
"node_modules/cross-fetch/node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=",
"dev": true,
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/cross-spawn": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz",
@ -14172,6 +14224,16 @@
"node": ">= 10.14.2"
}
},
"node_modules/jest-fetch-mock": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz",
"integrity": "sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw==",
"dev": true,
"dependencies": {
"cross-fetch": "^3.0.4",
"promise-polyfill": "^8.1.3"
}
},
"node_modules/jest-get-type": {
"version": "26.3.0",
"resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-26.3.0.tgz",
@ -15793,9 +15855,9 @@
}
},
"node_modules/jsdoc-wmf-theme": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/jsdoc-wmf-theme/-/jsdoc-wmf-theme-0.0.3.tgz",
"integrity": "sha512-jpszk0hcjY7bD1sCd8JrBdtcoudG0h9FbJTjdq8WOSEtUBNWgtIc7s1ccDoYnK/bp4OEuA7xH0xtpqe0SVutsw==",
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/jsdoc-wmf-theme/-/jsdoc-wmf-theme-0.0.5.tgz",
"integrity": "sha512-YRVucO3yiKF6a54oIR+gQLDynO60o2m0lOiCBCws0vIORJOn9T++tGJrOCVy5TSaSAmJTX1cnTbUCH7L+c1JCw==",
"dev": true,
"dependencies": {
"domino": "^2.0.1",
@ -18861,6 +18923,12 @@
"integrity": "sha1-mEcocL8igTL8vdhoEputEsPAKeM=",
"dev": true
},
"node_modules/promise-polyfill": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.2.1.tgz",
"integrity": "sha512-3p9zj0cEHbp7NVUxEYUWjQlffXqnXaZIMPkAO7HhFh8u5636xLRDHOUo2vpWSK0T2mqm6fKLXYn1KP6PAZ2gKg==",
"dev": true
},
"node_modules/promise.allsettled": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/promise.allsettled/-/promise.allsettled-1.0.4.tgz",
@ -30772,6 +30840,48 @@
"warning": "^4.0.3"
}
},
"cross-fetch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz",
"integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==",
"dev": true,
"requires": {
"node-fetch": "2.6.7"
},
"dependencies": {
"node-fetch": {
"version": "2.6.7",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz",
"integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==",
"dev": true,
"requires": {
"whatwg-url": "^5.0.0"
}
},
"tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=",
"dev": true
},
"webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=",
"dev": true
},
"whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=",
"dev": true,
"requires": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
}
}
},
"cross-spawn": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz",
@ -35892,6 +36002,16 @@
"jest-util": "^26.6.2"
}
},
"jest-fetch-mock": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz",
"integrity": "sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw==",
"dev": true,
"requires": {
"cross-fetch": "^3.0.4",
"promise-polyfill": "^8.1.3"
}
},
"jest-get-type": {
"version": "26.3.0",
"resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-26.3.0.tgz",
@ -37144,9 +37264,9 @@
"dev": true
},
"jsdoc-wmf-theme": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/jsdoc-wmf-theme/-/jsdoc-wmf-theme-0.0.3.tgz",
"integrity": "sha512-jpszk0hcjY7bD1sCd8JrBdtcoudG0h9FbJTjdq8WOSEtUBNWgtIc7s1ccDoYnK/bp4OEuA7xH0xtpqe0SVutsw==",
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/jsdoc-wmf-theme/-/jsdoc-wmf-theme-0.0.5.tgz",
"integrity": "sha512-YRVucO3yiKF6a54oIR+gQLDynO60o2m0lOiCBCws0vIORJOn9T++tGJrOCVy5TSaSAmJTX1cnTbUCH7L+c1JCw==",
"dev": true,
"requires": {
"domino": "^2.0.1",
@ -39569,6 +39689,12 @@
"integrity": "sha1-mEcocL8igTL8vdhoEputEsPAKeM=",
"dev": true
},
"promise-polyfill": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.2.1.tgz",
"integrity": "sha512-3p9zj0cEHbp7NVUxEYUWjQlffXqnXaZIMPkAO7HhFh8u5636xLRDHOUo2vpWSK0T2mqm6fKLXYn1KP6PAZ2gKg==",
"dev": true
},
"promise.allsettled": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/promise.allsettled/-/promise.allsettled-1.0.4.tgz",

View File

@ -31,8 +31,9 @@
"eslint-config-wikimedia": "0.20.0",
"grunt-banana-checker": "0.9.0",
"jest": "26.4.2",
"jest-fetch-mock": "3.0.3",
"jsdoc": "3.6.7",
"jsdoc-wmf-theme": "0.0.3",
"jsdoc-wmf-theme": "0.0.5",
"less": "3.8.1",
"less-loader": "4.1.0",
"mustache": "3.0.1",

View File

@ -1,5 +1,16 @@
{
"extends": [
"../../.eslintrcEs6.json"
]
],
"rules": {
"jsdoc/no-undefined-types": [
"error",
{
"definedTypes": [
"RequestInit",
"MwMap"
]
}
]
}
}

View File

@ -42,6 +42,7 @@
<script>
/* global SubmitEvent */
const wvui = require( 'wvui-search' ),
client = require( './restSearchClient.js' ),
instrumentation = require( './instrumentation.js' );
module.exports = {
@ -62,10 +63,10 @@ module.exports = {
/**
* Allow wikis eg. Hebrew Wikipedia to replace the default search API client
*
* @return {void|Object}
* @return {module:restSearchClient~SearchClient}
*/
getClient: () => {
return mw.config.get( 'wgVectorSearchClient', undefined );
return client( mw.config );
},
language: () => {
return mw.config.get( 'wgUserLanguage' );

View File

@ -0,0 +1,184 @@
/** @module restSearchClient */
/**
* @typedef {Object} AbortableFetch
* @property {Promise<any>} fetch
* @property {Function} abort
*/
/**
* @typedef {Object} NullableAbortController
* @property {AbortSignal | undefined} signal
* @property {Function} abort
*/
const nullAbortController = {
signal: undefined,
abort: () => {} // Do nothing (no-op)
};
/**
* A wrapper which combines native fetch() in browsers and the following json() call.
*
* @param {string} resource
* @param {RequestInit} [init]
* @return {AbortableFetch}
*/
function fetchJson( resource, init ) {
// As of 2020, browser support for AbortController is limited:
// https://caniuse.com/abortcontroller
// so replacing it with no-op if it doesn't exist.
/* eslint-disable compat/compat */
const controller = window.AbortController ?
new AbortController() :
nullAbortController;
/* eslint-enable compat/compat */
const getJson = fetch( resource, $.extend( init, {
signal: controller.signal
} ) ).then( ( response ) => {
if ( !response.ok ) {
return Promise.reject(
'Network request failed with HTTP code ' + response.status
);
}
return response.json();
} );
return {
fetch: getJson,
abort: () => {
controller.abort();
}
};
}
/**
* @typedef {Object} RestResponse
* @property {RestResult[]} pages
*/
/**
* @typedef {Object} RestResult
* @property {number} id
* @property {string} key
* @property {string} title
* @property {string} [description]
* @property {RestThumbnail | null} [thumbnail]
*
*/
/**
* @typedef {Object} RestThumbnail
* @property {string} url
* @property {number | null} [width]
* @property {number | null} [height]
*/
/**
* @typedef {Object} SearchResponse
* @property {string} query
* @property {SearchResult[]} results
*/
/**
* @typedef {Object} SearchResult
* @property {number} id
* @property {string} key
* @property {string} title
* @property {string} [description]
* @property {SearchResultThumbnail} [thumbnail]
*/
/**
* @typedef {Object} SearchResultThumbnail
* @property {string} url
* @property {number} [width]
* @property {number} [height]
*/
/**
* Nullish coalescing operator (??) helper
*
* @param {any} a
* @param {any} b
* @return {any}
*/
function nullish( a, b ) {
return ( a !== null && a !== undefined ) ? a : b;
}
/**
* @param {string} query
* @param {RestResponse} restResponse
* @return {SearchResponse}
*/
function adaptApiResponse( query, restResponse ) {
return {
query,
results: restResponse.pages.map( ( page ) => {
const thumbnail = page.thumbnail;
return {
id: page.id,
key: page.key,
title: page.title,
description: page.description,
thumbnail: thumbnail ? {
url: thumbnail.url,
width: nullish( thumbnail.width, undefined ),
height: nullish( thumbnail.height, undefined )
} : undefined
};
} )
};
}
/**
* @typedef {Object} AbortableSearchFetch
* @property {Promise<SearchResponse>} fetch
* @property {Function} abort
*/
/**
* @callback fetchByTitle
* @param {string} query The search term.
* @param {string} domain The base URL for the wiki without protocol. Example: 'sr.wikipedia.org'.
* @param {number} [limit] Maximum number of results.
* @return {AbortableSearchFetch}
*/
/**
* @typedef {Object} SearchClient
* @property {fetchByTitle} fetchByTitle
*/
/**
* @param {MwMap} config
* @return {SearchClient}
*/
function restSearchClient( config ) {
const customClient = config.get( 'wgVectorSearchClient' );
return customClient || {
/**
* @type {fetchByTitle}
*/
fetchByTitle: ( q, domain, limit = 10 ) => {
const params = { q, limit };
const url = '//' + domain + config.get( 'wgScriptPath' ) + '/rest.php/v1/search/title?' + $.param( params );
const result = fetchJson( url, {
headers: {
accept: 'application/json'
}
} );
const searchResponsePromise = result.fetch
.then( ( /** @type {RestResponse} */ res ) => {
return adaptApiResponse( q, res );
} );
return {
abort: result.abort,
fetch: searchResponsePromise
};
}
};
}
module.exports = restSearchClient;

View File

@ -145,6 +145,7 @@
"packageFiles": [
"resources/skins.vector.search/skins.vector.search.js",
"resources/skins.vector.search/instrumentation.js",
"resources/skins.vector.search/restSearchClient.js",
"resources/skins.vector.search/App.vue",
{
"name": "resources/skins.vector.search/config.json",

View File

@ -0,0 +1,121 @@
/* global fetchMock */
const restSearchClient = require( '../../resources/skins.vector.search/restSearchClient.js' );
const jestFetchMock = require( 'jest-fetch-mock' );
const mockedRequests = !process.env.TEST_LIVE_REQUESTS;
const configMock = {
get: jest.fn().mockImplementation( key => {
if ( key === 'wgScriptPath' ) {
return '/w';
}
return null;
} ),
set: jest.fn()
};
describe( 'restApiSearchClient', () => {
beforeAll( () => {
jestFetchMock.enableFetchMocks();
} );
afterAll( () => {
jestFetchMock.disableFetchMocks();
} );
beforeEach( () => {
fetchMock.resetMocks();
if ( !mockedRequests ) {
fetchMock.disableMocks();
}
} );
test( '2 results', async () => {
const thumbUrl = '//upload.wikimedia.org/wikipedia/commons/0/01/MediaWiki-smaller-logo.png';
const restResponse = {
pages: [
{
id: 37298,
key: 'Media',
title: 'Media',
description: 'Wikimedia disambiguation page',
thumbnail: null
},
{
id: 323710,
key: 'MediaWiki',
title: 'MediaWiki',
description: 'wiki software',
thumbnail: {
width: 200,
height: 189,
url: thumbUrl
}
}
]
};
fetchMock.mockOnce( JSON.stringify( restResponse ) );
const searchResult = await restSearchClient( configMock ).fetchByTitle(
'media',
'en.wikipedia.org',
2
).fetch;
/* eslint-disable-next-line compat/compat */
const controller = new AbortController();
expect( searchResult.query ).toStrictEqual( 'media' );
expect( searchResult.results ).toBeTruthy();
expect( searchResult.results.length ).toBe( 2 );
expect( searchResult.results[ 0 ] ).toStrictEqual(
Object.assign( {}, restResponse.pages[ 0 ], {
// thumbnail: null -> thumbnail: undefined
thumbnail: undefined
} ) );
expect( searchResult.results[ 1 ] ).toStrictEqual( restResponse.pages[ 1 ] );
if ( mockedRequests ) {
expect( fetchMock ).toHaveBeenCalledTimes( 1 );
expect( fetchMock ).toHaveBeenCalledWith(
'//en.wikipedia.org/w/rest.php/v1/search/title?q=media&limit=2',
{ headers: { accept: 'application/json' }, signal: controller.signal }
);
}
} );
test( '0 results', async () => {
const restResponse = { pages: [] };
fetchMock.mockOnce( JSON.stringify( restResponse ) );
const searchResult = await restSearchClient( configMock ).fetchByTitle(
'thereIsNothingLikeThis',
'en.wikipedia.org'
).fetch;
/* eslint-disable-next-line compat/compat */
const controller = new AbortController();
expect( searchResult.query ).toStrictEqual( 'thereIsNothingLikeThis' );
expect( searchResult.results ).toBeTruthy();
expect( searchResult.results.length ).toBe( 0 );
if ( mockedRequests ) {
expect( fetchMock ).toHaveBeenCalledTimes( 1 );
expect( fetchMock ).toHaveBeenCalledWith(
'//en.wikipedia.org/w/rest.php/v1/search/title?q=thereIsNothingLikeThis&limit=10',
{ headers: { accept: 'application/json' }, signal: controller.signal }
);
}
} );
if ( mockedRequests ) {
test( 'network error', async () => {
fetchMock.mockRejectOnce( new Error( 'failed' ) );
await expect( restSearchClient( configMock ).fetchByTitle(
'anything',
'en.wikipedia.org'
).fetch ).rejects.toThrow( 'failed' );
} );
}
} );