mirror of
https://github.com/strapi/strapi.git
synced 2025-11-15 17:49:57 +00:00
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:
parent
6b37320810
commit
e5d4b412da
@ -1,6 +1,8 @@
|
|||||||
import type { Core } from '@strapi/types';
|
import type { Core } from '@strapi/types';
|
||||||
import * as z from 'zod/v4';
|
import * as z from 'zod/v4';
|
||||||
|
|
||||||
|
import { createContentApiRoutesFactory } from '@strapi/utils';
|
||||||
|
|
||||||
const ctUIDRegexp = /^((strapi|admin)::[\w-]+|(api|plugin)::[\w-]+\.[\w-]+)$/;
|
const ctUIDRegexp = /^((strapi|admin)::[\w-]+|(api|plugin)::[\w-]+\.[\w-]+)$/;
|
||||||
const componentUIDRegexp = /^[\w-]+\.[\w-]+$/;
|
const componentUIDRegexp = /^[\w-]+\.[\w-]+$/;
|
||||||
|
|
||||||
@ -118,10 +120,8 @@ const formattedComponentSchema = z.object({
|
|||||||
schema: componentSchemaBase,
|
schema: componentSchemaBase,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default (): Core.RouterInput => {
|
const createRoutes = createContentApiRoutesFactory((): Core.RouterInput['routes'] => {
|
||||||
return {
|
return [
|
||||||
type: 'content-api',
|
|
||||||
routes: [
|
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/content-types',
|
path: '/content-types',
|
||||||
@ -159,6 +159,7 @@ export default (): Core.RouterInput => {
|
|||||||
},
|
},
|
||||||
response: z.object({ data: formattedComponentSchema }),
|
response: z.object({ data: formattedComponentSchema }),
|
||||||
},
|
},
|
||||||
],
|
];
|
||||||
};
|
});
|
||||||
};
|
|
||||||
|
export default createRoutes;
|
||||||
|
|||||||
@ -24,14 +24,17 @@ export const safeGlobalRegistrySet = (id: Internal.UID.Schema, schema: z.ZodType
|
|||||||
|
|
||||||
const transformedId = transformUidToValidOpenApiName(id);
|
const transformedId = transformUidToValidOpenApiName(id);
|
||||||
|
|
||||||
if (idMap.has(transformedId)) {
|
const isReplacing = idMap.has(transformedId);
|
||||||
|
|
||||||
|
if (isReplacing) {
|
||||||
// Remove existing schema to prevent conflicts
|
// Remove existing schema to prevent conflicts
|
||||||
strapi.log.debug(`Replacing existing schema ${transformedId} in registry`);
|
|
||||||
idMap.delete(transformedId);
|
idMap.delete(transformedId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register the new schema with the transformed ID
|
// 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 });
|
z.globalRegistry.add(schema, { id: transformedId });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
strapi.log.error(
|
strapi.log.error(
|
||||||
|
|||||||
@ -1,12 +1,11 @@
|
|||||||
import type { Core } from '@strapi/types';
|
import type { Core } from '@strapi/types';
|
||||||
|
import { createContentApiRoutesFactory } from '@strapi/utils';
|
||||||
import { EmailRouteValidator } from './validation';
|
import { EmailRouteValidator } from './validation';
|
||||||
|
|
||||||
export default (): Core.RouterInput => {
|
const createRoutes = createContentApiRoutesFactory((): Core.RouterInput['routes'] => {
|
||||||
const validator = new EmailRouteValidator(strapi);
|
const validator = new EmailRouteValidator(strapi);
|
||||||
|
|
||||||
return {
|
return [
|
||||||
type: 'content-api',
|
|
||||||
routes: [
|
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/',
|
path: '/',
|
||||||
@ -16,6 +15,7 @@ export default (): Core.RouterInput => {
|
|||||||
},
|
},
|
||||||
response: validator.emailResponse,
|
response: validator.emailResponse,
|
||||||
},
|
},
|
||||||
],
|
];
|
||||||
};
|
});
|
||||||
};
|
|
||||||
|
export default createRoutes;
|
||||||
|
|||||||
@ -1,13 +1,12 @@
|
|||||||
import type { Core } from '@strapi/types';
|
import type { Core } from '@strapi/types';
|
||||||
import * as z from 'zod/v4';
|
import * as z from 'zod/v4';
|
||||||
|
import { createContentApiRoutesFactory } from '@strapi/utils';
|
||||||
import { UploadRouteValidator } from './validation';
|
import { UploadRouteValidator } from './validation';
|
||||||
|
|
||||||
export const routes = (): Core.RouterInput => {
|
const createRoutes = createContentApiRoutesFactory((): Core.RouterInput['routes'] => {
|
||||||
const validator = new UploadRouteValidator(strapi);
|
const validator = new UploadRouteValidator(strapi);
|
||||||
|
|
||||||
return {
|
return [
|
||||||
type: 'content-api',
|
|
||||||
routes: [
|
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/',
|
path: '/',
|
||||||
@ -55,6 +54,7 @@ export const routes = (): Core.RouterInput => {
|
|||||||
},
|
},
|
||||||
response: validator.file,
|
response: validator.file,
|
||||||
},
|
},
|
||||||
],
|
];
|
||||||
};
|
});
|
||||||
};
|
|
||||||
|
export const routes = createRoutes;
|
||||||
|
|||||||
37
packages/core/utils/src/content-api-router.ts
Normal file
37
packages/core/utils/src/content-api-router.ts
Normal 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 };
|
||||||
@ -28,3 +28,4 @@ export * from './zod';
|
|||||||
export * from './validation';
|
export * from './validation';
|
||||||
|
|
||||||
export * from './primitives';
|
export * from './primitives';
|
||||||
|
export * from './content-api-router';
|
||||||
|
|||||||
@ -1,18 +1,17 @@
|
|||||||
import type { Core } from '@strapi/types';
|
import type { Core } from '@strapi/types';
|
||||||
|
import { createContentApiRoutesFactory } from '@strapi/utils';
|
||||||
import { I18nLocaleRouteValidator } from './validation';
|
import { I18nLocaleRouteValidator } from './validation';
|
||||||
|
|
||||||
export default (): Core.RouterInput => {
|
const createContentApiRoutes = createContentApiRoutesFactory((): Core.RouterInput['routes'] => {
|
||||||
const validator = new I18nLocaleRouteValidator(strapi);
|
const validator = new I18nLocaleRouteValidator(strapi);
|
||||||
|
return [
|
||||||
return {
|
|
||||||
type: 'content-api',
|
|
||||||
routes: [
|
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/locales',
|
path: '/locales',
|
||||||
handler: 'locales.listLocales',
|
handler: 'locales.listLocales',
|
||||||
response: validator.locales,
|
response: validator.locales,
|
||||||
},
|
},
|
||||||
],
|
];
|
||||||
};
|
});
|
||||||
};
|
|
||||||
|
export default createContentApiRoutes;
|
||||||
|
|||||||
@ -1,18 +1,18 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
const { createContentApiRoutesFactory } = require('@strapi/utils');
|
||||||
const authRoutes = require('./auth');
|
const authRoutes = require('./auth');
|
||||||
const userRoutes = require('./user');
|
const userRoutes = require('./user');
|
||||||
const roleRoutes = require('./role');
|
const roleRoutes = require('./role');
|
||||||
const permissionsRoutes = require('./permissions');
|
const permissionsRoutes = require('./permissions');
|
||||||
|
|
||||||
module.exports = (strapi) => {
|
const createContentApiRoutes = createContentApiRoutesFactory(() => {
|
||||||
return {
|
return [
|
||||||
type: 'content-api',
|
|
||||||
routes: [
|
|
||||||
...authRoutes(strapi),
|
...authRoutes(strapi),
|
||||||
...userRoutes(strapi),
|
...userRoutes(strapi),
|
||||||
...roleRoutes(strapi),
|
...roleRoutes(strapi),
|
||||||
...permissionsRoutes(strapi),
|
...permissionsRoutes(strapi),
|
||||||
],
|
];
|
||||||
};
|
});
|
||||||
};
|
|
||||||
|
module.exports = createContentApiRoutes;
|
||||||
|
|||||||
46
tests/api/core/strapi/plugin-routes-extension-bc.test.api.ts
Normal file
46
tests/api/core/strapi/plugin-routes-extension-bc.test.api.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
59
tests/cli/tests/strapi/strapi/openapi-generate.test.cli.ts
Normal file
59
tests/cli/tests/strapi/strapi/openapi-generate.test.cli.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user