diff --git a/packages/core/content-type-builder/server/src/routes/content-api.ts b/packages/core/content-type-builder/server/src/routes/content-api.ts index 6d4920988d..484536e87f 100644 --- a/packages/core/content-type-builder/server/src/routes/content-api.ts +++ b/packages/core/content-type-builder/server/src/routes/content-api.ts @@ -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; diff --git a/packages/core/core/src/core-api/routes/validation/utils.ts b/packages/core/core/src/core-api/routes/validation/utils.ts index fca7ac5be0..f1d8d6f2dc 100644 --- a/packages/core/core/src/core-api/routes/validation/utils.ts +++ b/packages/core/core/src/core-api/routes/validation/utils.ts @@ -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( diff --git a/packages/core/email/server/src/routes/content-api.ts b/packages/core/email/server/src/routes/content-api.ts index 387881e67c..f9e7a8b051 100644 --- a/packages/core/email/server/src/routes/content-api.ts +++ b/packages/core/email/server/src/routes/content-api.ts @@ -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; diff --git a/packages/core/upload/server/src/routes/content-api.ts b/packages/core/upload/server/src/routes/content-api.ts index d1c66ed8dd..95a15a4a71 100644 --- a/packages/core/upload/server/src/routes/content-api.ts +++ b/packages/core/upload/server/src/routes/content-api.ts @@ -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; diff --git a/packages/core/utils/src/content-api-router.ts b/packages/core/utils/src/content-api-router.ts new file mode 100644 index 0000000000..aba92d5aec --- /dev/null +++ b/packages/core/utils/src/content-api-router.ts @@ -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 = (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 = ReturnType< + typeof createContentApiRoutesFactory +> & { routes: TRoutes }; diff --git a/packages/core/utils/src/index.ts b/packages/core/utils/src/index.ts index f6459fa0eb..e1f1110191 100644 --- a/packages/core/utils/src/index.ts +++ b/packages/core/utils/src/index.ts @@ -28,3 +28,4 @@ export * from './zod'; export * from './validation'; export * from './primitives'; +export * from './content-api-router'; diff --git a/packages/plugins/i18n/server/src/routes/content-api.ts b/packages/plugins/i18n/server/src/routes/content-api.ts index 0af09ae635..3999623236 100644 --- a/packages/plugins/i18n/server/src/routes/content-api.ts +++ b/packages/plugins/i18n/server/src/routes/content-api.ts @@ -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; diff --git a/packages/plugins/users-permissions/server/routes/content-api/index.js b/packages/plugins/users-permissions/server/routes/content-api/index.js index 33ccd17fa6..e228bbe97b 100644 --- a/packages/plugins/users-permissions/server/routes/content-api/index.js +++ b/packages/plugins/users-permissions/server/routes/content-api/index.js @@ -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; diff --git a/tests/api/core/strapi/plugin-routes-extension-bc.test.api.ts b/tests/api/core/strapi/plugin-routes-extension-bc.test.api.ts new file mode 100644 index 0000000000..d0252bec0e --- /dev/null +++ b/tests/api/core/strapi/plugin-routes-extension-bc.test.api.ts @@ -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); + }); +}); diff --git a/tests/cli/tests/strapi/strapi/openapi-generate.test.cli.ts b/tests/cli/tests/strapi/strapi/openapi-generate.test.cli.ts new file mode 100644 index 0000000000..5b90647287 --- /dev/null +++ b/tests/cli/tests/strapi/strapi/openapi-generate.test.cli.ts @@ -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); + }); +});