diff --git a/src/utils/api-fetch.js b/src/utils/api-fetch.js index 5f8885e4d..df9f9bb07 100644 --- a/src/utils/api-fetch.js +++ b/src/utils/api-fetch.js @@ -34,6 +34,7 @@ export function configureApiFetch() { apiFetch.use( nativeMediaUploadMiddleware ); apiFetch.use( mediaUploadMiddleware ); apiFetch.use( transformOEmbedApiResponse ); + apiFetch.use( siteIndexMiddleware ); apiFetch.use( apiFetch.createPreloadingMiddleware( preloadData ?? defaultPreloadData ) ); @@ -460,6 +461,54 @@ function transformOEmbedApiResponse( options, next ) { return next( options, next ); } +/** + * Middleware resolving the REST API index locally on namespaced sites. + * + * Gutenberg's `root`/`__unstableBase` entity fetches the REST API index (`/`) + * during editor initialization. On a namespaced site that path has no segments + * for `apiPathModifierMiddleware` to insert the namespace into, so the request + * targets the API host's root, which serves no index. Rather than let the + * request fail, resolve the entity with `home` from the host's site URL. The + * host supplies a single URL, so `url`, the WordPress address, has no accurate + * source and is left unset. + * + * Consumers tolerate the remaining fields being absent: the site blocks read + * the `site` entity when the user can edit settings, and client-side media + * processing treats missing image sizes as none. + * + * Runs after the preloading middleware so a host-supplied index entry takes + * precedence. `apiFetch.use()` prepends, so this is registered immediately + * before it. + * + * @type {APIFetchMiddleware} + */ +function siteIndexMiddleware( options, next ) { + const { siteApiNamespace = [], siteURL } = getGBKit(); + const isNamespacedSite = siteApiNamespace.length > 0; + const isGet = ! options.method || options.method.toUpperCase() === 'GET'; + + if ( ! isNamespacedSite || ! isGet || ! isRestIndexPath( options.path ) ) { + return next( options ); + } + + const home = siteURL?.replace( /\/+$/, '' ); + return Promise.resolve( home ? { home } : {} ); +} + +/** + * Whether a request path targets the REST API index. + * + * @param {string} [path] The request path, e.g. `/?_fields=name`. + * @return {boolean} True for `/` with or without a query string. + */ +function isRestIndexPath( path ) { + if ( typeof path !== 'string' ) { + return false; + } + const pathname = path.split( '?' )[ 0 ]; + return pathname === '' || pathname === '/'; +} + const defaultPreloadData = { '/wp/v2/types?context=view': { body: { diff --git a/src/utils/api-fetch.test.js b/src/utils/api-fetch.test.js index f340f9277..d75339bfe 100644 --- a/src/utils/api-fetch.test.js +++ b/src/utils/api-fetch.test.js @@ -194,6 +194,71 @@ describe( 'api-fetch credentials handling', () => { } ); } ); + describe( 'siteIndexMiddleware', () => { + const indexPath = '/?_fields=name,home,url,image_sizes'; + + it( 'resolves the REST index locally on namespaced sites', async () => { + bridge.getGBKit.mockReturnValue( { + siteApiRoot: 'https://public-api.example.com/', + siteApiNamespace: [ 'sites/123/' ], + namespaceExcludedPaths: [], + siteURL: 'https://example.com/', + } ); + + const result = await apiFetch( { path: indexPath } ); + + expect( global.fetch ).not.toHaveBeenCalled(); + expect( result ).toEqual( { home: 'https://example.com' } ); + } ); + + it( 'resolves an empty record when the site URL is unknown', async () => { + bridge.getGBKit.mockReturnValue( { + siteApiRoot: 'https://public-api.example.com/', + siteApiNamespace: [ 'sites/123/' ], + namespaceExcludedPaths: [], + } ); + + const result = await apiFetch( { path: '/' } ); + + expect( global.fetch ).not.toHaveBeenCalled(); + expect( result ).toEqual( {} ); + } ); + + it( 'requests the REST index from sites without a namespace', async () => { + bridge.getGBKit.mockReturnValue( { + siteApiRoot: 'https://example.com/wp-json/', + siteApiNamespace: [], + namespaceExcludedPaths: [], + siteURL: 'https://example.com/', + } ); + + await apiFetch( { path: indexPath } ); + + expect( global.fetch ).toHaveBeenCalled(); + const [ url ] = global.fetch.mock.calls[ 0 ]; + expect( url ).toMatch( + /^https:\/\/example\.com\/wp-json\/\?_fields=/ + ); + } ); + + it( 'lets non-index requests through on namespaced sites', async () => { + bridge.getGBKit.mockReturnValue( { + siteApiRoot: 'https://public-api.example.com/', + siteApiNamespace: [ 'sites/123/' ], + namespaceExcludedPaths: [], + siteURL: 'https://example.com/', + } ); + + try { + await apiFetch( { path: '/wp/v2/posts' } ); + } catch ( error ) { + // Ignore errors from the actual fetch + } + + expect( global.fetch ).toHaveBeenCalled(); + } ); + } ); + it( 'should preserve other headers when adding Authorization', async () => { bridge.getGBKit.mockReturnValue( { siteApiRoot: 'https://example.com/wp-json/', diff --git a/src/utils/bridge.js b/src/utils/bridge.js index f04b50830..45f84174d 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -225,6 +225,7 @@ export function onNetworkRequest( requestData ) { * @typedef GBKitConfig * * @property {boolean} [themeStyles] Controls if theme styles are applied to the editor. + * @property {string} [siteURL] The site's home URL. * @property {string} [siteApiRoot] The root URL of the site's API. * @property {string[]} [siteApiNamespace] The namespace of the site's API; if multiple namespaces are provided, the first one is used as the default. * @property {string[]} [namespaceExcludedPaths] The paths that should not be namespaced.