Fix plugin content-api route extensions (#24147)

* fix: content-api route extension for i18n and users-permissions

* refactor: simplify tests to catch breaking change

* test: cli for openapi plugin routes

* refactor: implement createcontentapiroutesfactory for content-api routes across multiple packages

* fix: more accurate message

* chore: formatting

* chore: cleanup backward compatibility tests

---------

Co-authored-by: Ziyi Yuan <daydreamnation@live.com>
This commit is contained in:
Jamie Howard 2025-08-18 12:28:39 +01:00 committed by GitHub
parent 6b37320810
commit e5d4b412da
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 276 additions and 130 deletions

View File

@ -1,6 +1,8 @@
import type { Core } from '@strapi/types';
import * as z from 'zod/v4';
import { createContentApiRoutesFactory } from '@strapi/utils';
const ctUIDRegexp = /^((strapi|admin)::[\w-]+|(api|plugin)::[\w-]+\.[\w-]+)$/;
const componentUIDRegexp = /^[\w-]+\.[\w-]+$/;
@ -118,47 +120,46 @@ const formattedComponentSchema = z.object({
schema: componentSchemaBase,
});
export default (): Core.RouterInput => {
return {
type: 'content-api',
routes: [
{
method: 'GET',
path: '/content-types',
handler: 'content-types.getContentTypes',
request: {
query: { kind: z.enum(['collectionType', 'singleType']) },
const createRoutes = createContentApiRoutesFactory((): Core.RouterInput['routes'] => {
return [
{
method: 'GET',
path: '/content-types',
handler: 'content-types.getContentTypes',
request: {
query: { kind: z.enum(['collectionType', 'singleType']) },
},
response: z.object({ data: z.array(formattedContentTypeSchema) }),
},
{
method: 'GET',
path: '/content-types/:uid',
handler: 'content-types.getContentType',
request: {
params: {
uid: z.string().regex(ctUIDRegexp),
},
response: z.object({ data: z.array(formattedContentTypeSchema) }),
},
{
method: 'GET',
path: '/content-types/:uid',
handler: 'content-types.getContentType',
request: {
params: {
uid: z.string().regex(ctUIDRegexp),
},
response: z.object({ data: formattedContentTypeSchema }),
},
{
method: 'GET',
path: '/components',
handler: 'components.getComponents',
response: z.object({ data: z.array(formattedComponentSchema) }),
},
{
method: 'GET',
path: '/components/:uid',
handler: 'components.getComponent',
request: {
params: {
uid: z.string().regex(componentUIDRegexp),
},
response: z.object({ data: formattedContentTypeSchema }),
},
{
method: 'GET',
path: '/components',
handler: 'components.getComponents',
response: z.object({ data: z.array(formattedComponentSchema) }),
},
{
method: 'GET',
path: '/components/:uid',
handler: 'components.getComponent',
request: {
params: {
uid: z.string().regex(componentUIDRegexp),
},
},
response: z.object({ data: formattedComponentSchema }),
},
],
};
};
response: z.object({ data: formattedComponentSchema }),
},
];
});
export default createRoutes;

View File

@ -24,14 +24,17 @@ export const safeGlobalRegistrySet = (id: Internal.UID.Schema, schema: z.ZodType
const transformedId = transformUidToValidOpenApiName(id);
if (idMap.has(transformedId)) {
const isReplacing = idMap.has(transformedId);
if (isReplacing) {
// Remove existing schema to prevent conflicts
strapi.log.debug(`Replacing existing schema ${transformedId} in registry`);
idMap.delete(transformedId);
}
// Register the new schema with the transformed ID
strapi.log.debug(`Registering schema ${transformedId} in global registry`);
strapi.log.debug(
`${isReplacing ? 'Replacing' : 'Registering'} schema ${transformedId} in global registry`
);
z.globalRegistry.add(schema, { id: transformedId });
} catch (error) {
strapi.log.error(

View File

@ -1,21 +1,21 @@
import type { Core } from '@strapi/types';
import { createContentApiRoutesFactory } from '@strapi/utils';
import { EmailRouteValidator } from './validation';
export default (): Core.RouterInput => {
const createRoutes = createContentApiRoutesFactory((): Core.RouterInput['routes'] => {
const validator = new EmailRouteValidator(strapi);
return {
type: 'content-api',
routes: [
{
method: 'POST',
path: '/',
handler: 'email.send',
request: {
body: { 'application/json': validator.sendEmailInput },
},
response: validator.emailResponse,
return [
{
method: 'POST',
path: '/',
handler: 'email.send',
request: {
body: { 'application/json': validator.sendEmailInput },
},
],
};
};
response: validator.emailResponse,
},
];
});
export default createRoutes;

View File

@ -1,60 +1,60 @@
import type { Core } from '@strapi/types';
import * as z from 'zod/v4';
import { createContentApiRoutesFactory } from '@strapi/utils';
import { UploadRouteValidator } from './validation';
export const routes = (): Core.RouterInput => {
const createRoutes = createContentApiRoutesFactory((): Core.RouterInput['routes'] => {
const validator = new UploadRouteValidator(strapi);
return {
type: 'content-api',
routes: [
{
method: 'POST',
path: '/',
handler: 'content-api.upload',
request: {
query: { id: validator.fileId.optional() },
// Note: multipart/form-data is handled by Koa middleware, not Zod
},
response: z.union([validator.file, validator.files]),
return [
{
method: 'POST',
path: '/',
handler: 'content-api.upload',
request: {
query: { id: validator.fileId.optional() },
// Note: multipart/form-data is handled by Koa middleware, not Zod
},
{
method: 'GET',
path: '/files',
handler: 'content-api.find',
request: {
query: {
fields: validator.queryFields.optional(),
populate: validator.queryPopulate.optional(),
sort: validator.querySort.optional(),
pagination: validator.pagination.optional(),
filters: validator.filters.optional(),
},
response: z.union([validator.file, validator.files]),
},
{
method: 'GET',
path: '/files',
handler: 'content-api.find',
request: {
query: {
fields: validator.queryFields.optional(),
populate: validator.queryPopulate.optional(),
sort: validator.querySort.optional(),
pagination: validator.pagination.optional(),
filters: validator.filters.optional(),
},
response: validator.files,
},
{
method: 'GET',
path: '/files/:id',
handler: 'content-api.findOne',
request: {
params: { id: validator.fileId },
query: {
fields: validator.queryFields.optional(),
populate: validator.queryPopulate.optional(),
},
response: validator.files,
},
{
method: 'GET',
path: '/files/:id',
handler: 'content-api.findOne',
request: {
params: { id: validator.fileId },
query: {
fields: validator.queryFields.optional(),
populate: validator.queryPopulate.optional(),
},
response: validator.file,
},
{
method: 'DELETE',
path: '/files/:id',
handler: 'content-api.destroy',
request: {
params: { id: validator.fileId },
},
response: validator.file,
response: validator.file,
},
{
method: 'DELETE',
path: '/files/:id',
handler: 'content-api.destroy',
request: {
params: { id: validator.fileId },
},
],
};
};
response: validator.file,
},
];
});
export const routes = createRoutes;

View File

@ -0,0 +1,37 @@
/**
* Creates a content-api route factory that exposes `routes` on the factory function for backward compatibility.
*
* This allows legacy extensions to mutate `plugin.routes["content-api"].routes` directly.
*/
export const createContentApiRoutesFactory = <TRoutes>(buildRoutes: () => TRoutes) => {
let sharedRoutes: TRoutes | undefined;
const ensureSharedRoutes = (): TRoutes => {
if (!sharedRoutes) {
sharedRoutes = buildRoutes();
}
return sharedRoutes;
};
const createContentApiRoutes = () => {
return {
type: 'content-api' as const,
routes: ensureSharedRoutes(),
};
};
Object.defineProperty(createContentApiRoutes, 'routes', {
get: ensureSharedRoutes,
set(next: TRoutes) {
sharedRoutes = next;
},
enumerable: true,
});
return createContentApiRoutes;
};
export type ContentApiRoutesFactory<TRoutes> = ReturnType<
typeof createContentApiRoutesFactory<TRoutes>
> & { routes: TRoutes };

View File

@ -28,3 +28,4 @@ export * from './zod';
export * from './validation';
export * from './primitives';
export * from './content-api-router';

View File

@ -1,18 +1,17 @@
import type { Core } from '@strapi/types';
import { createContentApiRoutesFactory } from '@strapi/utils';
import { I18nLocaleRouteValidator } from './validation';
export default (): Core.RouterInput => {
const createContentApiRoutes = createContentApiRoutesFactory((): Core.RouterInput['routes'] => {
const validator = new I18nLocaleRouteValidator(strapi);
return [
{
method: 'GET',
path: '/locales',
handler: 'locales.listLocales',
response: validator.locales,
},
];
});
return {
type: 'content-api',
routes: [
{
method: 'GET',
path: '/locales',
handler: 'locales.listLocales',
response: validator.locales,
},
],
};
};
export default createContentApiRoutes;

View File

@ -1,18 +1,18 @@
'use strict';
const { createContentApiRoutesFactory } = require('@strapi/utils');
const authRoutes = require('./auth');
const userRoutes = require('./user');
const roleRoutes = require('./role');
const permissionsRoutes = require('./permissions');
module.exports = (strapi) => {
return {
type: 'content-api',
routes: [
...authRoutes(strapi),
...userRoutes(strapi),
...roleRoutes(strapi),
...permissionsRoutes(strapi),
],
};
};
const createContentApiRoutes = createContentApiRoutesFactory(() => {
return [
...authRoutes(strapi),
...userRoutes(strapi),
...roleRoutes(strapi),
...permissionsRoutes(strapi),
];
});
module.exports = createContentApiRoutes;

View File

@ -0,0 +1,46 @@
import path from 'path';
import fs from 'node:fs/promises';
import { createStrapiInstance } from 'api-tests/strapi';
const writeFileSafe = async (filePath, contents) => {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, contents);
};
const pluginExtensionSource = `
module.exports = (plugin) => {
plugin.routes["content-api"].routes = plugin.routes["content-api"].routes.map((route) => {
return route;
});
return plugin;
};
`;
describe('Plugin route extension backward compatibility', () => {
let strapi;
beforeAll(async () => {
const appRoot = path.resolve(__dirname, '../../../../test-apps/api');
const upExtPath = path.join(appRoot, 'src/extensions/users-permissions/strapi-server.js');
const i18nExtPath = path.join(appRoot, 'src/extensions/i18n/strapi-server.js');
await Promise.all([
writeFileSafe(upExtPath, pluginExtensionSource),
writeFileSafe(i18nExtPath, pluginExtensionSource),
]);
strapi = await createStrapiInstance();
});
afterAll(async () => {
await strapi.destroy();
});
test('server starts when plugins mutate content-api routes', async () => {
expect(strapi.server).toBeDefined();
expect(strapi.server.httpServer.listening).toBe(true);
});
});

View File

@ -0,0 +1,59 @@
import path from 'node:path';
import fs from 'node:fs/promises';
import coffee from 'coffee';
import utils from '../../../utils';
/**
* This test ensures that `strapi openapi generate` includes content API routes
* from the i18n and users-permissions plugins in the generated specification.
*/
describe('openapi:generate', () => {
let appPath;
const OUTPUT_FILE = 'openapi-spec.json';
beforeAll(async () => {
const testApps = utils.instances.getTestApps();
appPath = testApps.at(0);
});
afterEach(async () => {
try {
await fs.unlink(path.join(appPath, OUTPUT_FILE));
} catch (_) {}
});
it('should generate a spec describing i18n and users-permissions content API routes', async () => {
await coffee
.spawn('npm', ['run', '-s', 'strapi', 'openapi', 'generate', '--', '-o', OUTPUT_FILE], {
cwd: appPath,
})
.expect('code', 0)
.end();
// Generate an openapi spec for the test app and read it's contents
const specPath = path.join(appPath, OUTPUT_FILE);
const raw = await fs.readFile(specPath, 'utf8');
const spec = JSON.parse(raw);
expect(spec).toBeDefined();
expect(spec.paths).toBeDefined();
const paths = Object.keys(spec.paths || {});
const hasPathEndingWith = (suffix: string) => paths.some((p) => p.endsWith(suffix));
// Checking for known plugin routes - ensuring the plugins are covered by
// the openapi spec
// i18n
expect(hasPathEndingWith('/locales')).toBe(true);
// users-permissions
expect(hasPathEndingWith('/auth/local')).toBe(true);
expect(hasPathEndingWith('/auth/local/register')).toBe(true);
expect(hasPathEndingWith('/auth/{provider}/callback')).toBe(true);
expect(hasPathEndingWith('/users')).toBe(true);
expect(hasPathEndingWith('/users/{id}')).toBe(true);
});
});