Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions src/utils/api-fetch.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 )
);
Expand Down Expand Up @@ -460,6 +461,52 @@ 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 the fields the host already provides.
*
* 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, url: 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: {
Expand Down
68 changes: 68 additions & 0 deletions src/utils/api-fetch.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,74 @@ 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',
url: '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/',
Expand Down
1 change: 1 addition & 0 deletions src/utils/bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading