Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ data class GBKitGlobal(
val id: Int, // TODO: Instead of the `-1` trick, this should just be `null` for new posts
/** The post type (e.g., `post`, `page`). */
val type: String,
/** The REST API base path for this post type (e.g., `posts`, `pages`). */
val restBase: String,
/** The REST API namespace (e.g., `wp/v2`). */
val restNamespace: String,
/** The post status (e.g., `draft`, `publish`, `pending`). */
val status: String,
/** The post title (URL-encoded). */
Expand Down Expand Up @@ -110,6 +114,8 @@ data class GBKitGlobal(
post = Post(
id = postId ?: -1,
type = configuration.postType,
restBase = restBaseFor(configuration.postType),
restNamespace = "wp/v2",
status = configuration.postStatus,
title = configuration.title.encodeForEditor(),
content = configuration.content.encodeForEditor()
Expand All @@ -126,6 +132,18 @@ data class GBKitGlobal(
}
)
}

/**
* Maps a post type slug to its WordPress REST API base path.
*
* Defaults to pluralizing the slug for unknown types (e.g., `product` → `products`),
* which matches the WordPress convention for most post types.
*/
private fun restBaseFor(postType: String): String = when (postType) {
"post" -> "posts"
"page" -> "pages"
else -> if (postType.endsWith("s")) postType else "${postType}s"
}
Comment on lines +136 to +146
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will be removed and replaced with fetching of PostTypeDetails in a future PR focused on editing existing posts (and align with iOS). In the interim, this ensures Android correctly sets the restBase value for posts and pages.

}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,29 @@ class GBKitGlobalTest {
assertEquals(-1, globalWithout.post.id)
}

@Test
fun `populates restBase and restNamespace for post type`() {
val configuration = makeConfiguration(postType = "post")
val global = GBKitGlobal.fromConfiguration(configuration, makeDependencies())
assertEquals("posts", global.post.restBase)
assertEquals("wp/v2", global.post.restNamespace)
}

@Test
fun `populates restBase for page post type`() {
val configuration = makeConfiguration(postType = "page")
val global = GBKitGlobal.fromConfiguration(configuration, makeDependencies())
assertEquals("pages", global.post.restBase)
assertEquals("wp/v2", global.post.restNamespace)
}

@Test
fun `pluralizes custom post type slugs for restBase`() {
val configuration = makeConfiguration(postType = "product")
val global = GBKitGlobal.fromConfiguration(configuration, makeDependencies())
assertEquals("products", global.post.restBase)
}

@Test
fun `maps zero postID to negative one`() {
val configuration = makeConfiguration(postId = 0u)
Expand Down
23 changes: 19 additions & 4 deletions src/utils/api-fetch.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { getQueryArg } from '@wordpress/url';
/**
* Internal dependencies
*/
import { getGBKit } from './bridge';
import { getGBKit, POST_FALLBACKS } from './bridge';

/**
* @typedef {import('@wordpress/api-fetch').APIFetchMiddleware} APIFetchMiddleware
Expand Down Expand Up @@ -111,16 +111,31 @@ function tokenAuthMiddleware( options, next ) {
* Middleware to filter out requests to specific endpoints.
*
* @type {APIFetchMiddleware}
*
* @todo Properly seed the post entity and remove this middleware.
*
* This was added to prevent re-fetching entity content provided by the native
* host app, which can lead to content loss. However, we can likely avoid the
* need for this middleware by ensuring we properly seed the entity content into
* the store on initialization.
*
* This requires hoisting the relevant logic from `useEditorSetup` to occur
* before we render the editor, and invoking `finishResolution`.
*
* See: https://github.com/wordpress-mobile/GutenbergKit/commit/c9b4fc9978a3760ba97f3f5d4359c2bc2155bb80
Comment on lines +115 to +125
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the future, we can improve entity seeding and negate the need this middleware that manually disables the entity fetch requests. See c9b4fc9 as a proof-of-concept.

*/
function filterEndpointsMiddleware( options, next ) {
const { post } = getGBKit();
const { id, restNamespace, restBase } = post ?? {};

if ( id === undefined || ! restNamespace || ! restBase ) {
if ( ! post || post.id === undefined ) {
return next( options );
}

const disabledPath = `/${ restNamespace }/${ restBase }/${ id }`;
// Apply the same fallback contract as `getPost()` so the filter still
// engages on hosts whose payload omits restBase/restNamespace.
const restNamespace = post.restNamespace || POST_FALLBACKS.restNamespace;
const restBase = post.restBase || POST_FALLBACKS.restBase;
const disabledPath = `/${ restNamespace }/${ restBase }/${ post.id }`;

if (
options.path === disabledPath ||
Expand Down
78 changes: 77 additions & 1 deletion src/utils/api-fetch.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,13 @@ import apiFetch from '@wordpress/api-fetch';
import { configureApiFetch } from './api-fetch';
import * as bridge from './bridge';

vi.mock( './bridge' );
vi.mock( './bridge', async ( importOriginal ) => {
const actual = await importOriginal();
return {
...actual,
getGBKit: vi.fn(),
};
} );

describe( 'api-fetch credentials handling', () => {
let originalFetch;
Expand Down Expand Up @@ -118,6 +124,76 @@ describe( 'api-fetch credentials handling', () => {
expect( options.headers.Authorization ).toBe( 'Bearer override-token' );
} );

describe( 'filterEndpointsMiddleware', () => {
it( 'filters the post endpoint when restBase and restNamespace are provided', async () => {
bridge.getGBKit.mockReturnValue( {
siteApiRoot: 'https://example.com/wp-json/',
siteApiNamespace: [ 'wp/v2' ],
namespaceExcludedPaths: [],
post: {
id: 42,
restBase: 'posts',
restNamespace: 'wp/v2',
},
} );

const result = await apiFetch( { path: '/wp/v2/posts/42' } );

expect( global.fetch ).not.toHaveBeenCalled();
expect( result ).toEqual( [] );
} );

it( 'falls back to default restBase and restNamespace when omitted from the payload', async () => {
bridge.getGBKit.mockReturnValue( {
siteApiRoot: 'https://example.com/wp-json/',
siteApiNamespace: [ 'wp/v2' ],
namespaceExcludedPaths: [],
post: {
id: 7,
// restBase and restNamespace intentionally omitted
},
} );

const result = await apiFetch( { path: '/wp/v2/posts/7' } );

expect( global.fetch ).not.toHaveBeenCalled();
expect( result ).toEqual( [] );
} );

it( 'lets the request through when post id is undefined', async () => {
bridge.getGBKit.mockReturnValue( {
siteApiRoot: 'https://example.com/wp-json/',
siteApiNamespace: [ 'wp/v2' ],
namespaceExcludedPaths: [],
post: {},
} );

try {
await apiFetch( { path: '/wp/v2/posts/99' } );
} catch ( error ) {
// Ignore errors from the actual fetch
}

expect( global.fetch ).toHaveBeenCalled();
} );

it( 'lets the request through when no post payload is present', async () => {
bridge.getGBKit.mockReturnValue( {
siteApiRoot: 'https://example.com/wp-json/',
siteApiNamespace: [ 'wp/v2' ],
namespaceExcludedPaths: [],
} );

try {
await apiFetch( { path: '/wp/v2/posts/99' } );
} 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
39 changes: 24 additions & 15 deletions src/utils/bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,19 @@ export function onNetworkRequest( requestData ) {
* @property {boolean} [enableNetworkLogging] Enables logging of all network requests/responses to the native host via onNetworkRequest bridge method.
*/

/**
* Default values used when the native host omits fields from the post payload.
* Centralized here so all consumers (getPost, filterEndpointsMiddleware) agree
* on the fallback contract.
*/
export const POST_FALLBACKS = {
id: -1,
type: 'post',
restBase: 'posts',
restNamespace: 'wp/v2',
status: 'draft',
};

/**
* Retrieves the native-host-provided GBKit object from localStorage or returns
* an empty object if not found.
Expand Down Expand Up @@ -300,11 +313,11 @@ export async function getPost() {
if ( hostContent ) {
debug( 'Using content from native host' );
return {
id: post?.id || -1,
type: post?.type || 'post',
restBase: post?.restBase || 'posts',
restNamespace: post?.restNamespace || 'wp/v2',
status: post?.status || 'draft',
id: post?.id || POST_FALLBACKS.id,
type: post?.type || POST_FALLBACKS.type,
restBase: post?.restBase || POST_FALLBACKS.restBase,
restNamespace: post?.restNamespace || POST_FALLBACKS.restNamespace,
status: post?.status || POST_FALLBACKS.status,
title: { raw: hostContent.title },
content: { raw: hostContent.content },
};
Expand All @@ -313,23 +326,19 @@ export async function getPost() {
if ( post ) {
debug( 'Native bridge unavailable, using GBKit initial content' );
return {
id: post.id || -1,
type: post.type || 'post',
restBase: post.restBase || 'posts',
restNamespace: post.restNamespace || 'wp/v2',
status: post.status || 'draft',
id: post.id || POST_FALLBACKS.id,
type: post.type || POST_FALLBACKS.type,
restBase: post.restBase || POST_FALLBACKS.restBase,
restNamespace: post.restNamespace || POST_FALLBACKS.restNamespace,
status: post.status || POST_FALLBACKS.status,
title: { raw: decodeURIComponent( post.title ) },
content: { raw: decodeURIComponent( post.content ) },
};
}

// Fallback to default empty post
return {
id: -1,
type: 'post',
restBase: 'posts',
restNamespace: 'wp/v2',
status: 'draft',
...POST_FALLBACKS,
title: { raw: '' },
content: { raw: '' },
};
Expand Down
Loading