diff --git a/src/client.ts b/src/client.ts index 5339db5..874cb76 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2,7 +2,7 @@ import createDebug from 'debug'; import { AuthManager } from './auth'; import { CollectionTypeManager, SingleTypeManager } from './content-types'; -import { WELL_KNOWN_STRAPI_RESOURCES } from './content-types/constants'; +import { getWellKnownCollection, getWellKnownSingle } from './content-types/constants'; import { StrapiError, StrapiInitializationError } from './errors'; import { FilesManager } from './files'; import { HttpClient } from './http'; @@ -363,9 +363,10 @@ export class StrapiClient { collection(resource: string, options: ClientCollectionOptions = {}) { const { path, plugin } = options; - // Auto-detect well-known Strapi resources and apply their plugin configuration + // Auto-detect well-known collection resources and apply their plugin configuration // if no explicit plugin option is provided - const effectivePlugin = plugin ?? WELL_KNOWN_STRAPI_RESOURCES[resource]?.plugin ?? undefined; + const wellKnownConfig = getWellKnownCollection(resource); + const effectivePlugin = plugin ?? wellKnownConfig?.plugin ?? undefined; return new CollectionTypeManager({ resource, path, plugin: effectivePlugin }, this._httpClient); } @@ -407,12 +408,17 @@ export class StrapiClient { * @see StrapiClient */ single(resource: string, options: SingleCollectionOptions = {}) { - const { path } = options; + const { path, plugin } = options; + + // Auto-detect well-known single-type resources and apply their plugin configuration + // if no explicit plugin option is provided + const wellKnownConfig = getWellKnownSingle(resource); + const effectivePlugin = plugin ?? wellKnownConfig?.plugin ?? undefined; - return new SingleTypeManager({ resource, path }, this._httpClient); + return new SingleTypeManager({ resource, path, plugin: effectivePlugin }, this._httpClient); } } // Local Client Types export type ClientCollectionOptions = Pick; -export type SingleCollectionOptions = Pick; +export type SingleCollectionOptions = Pick; diff --git a/src/content-types/collection/manager.ts b/src/content-types/collection/manager.ts index aa38384..3c40e61 100644 --- a/src/content-types/collection/manager.ts +++ b/src/content-types/collection/manager.ts @@ -3,7 +3,7 @@ import createDebug from 'debug'; import { HttpClient } from '../../http'; import { URLHelper } from '../../utilities'; import { AbstractContentTypeManager } from '../abstract'; -import { pluginsThatDoNotWrapDataAttribute } from '../constants'; +import { shouldWrapData } from '../constants'; import type * as API from '../../types/content-api'; import type { ContentTypeManagerOptions } from '../abstract'; @@ -42,15 +42,14 @@ export class CollectionTypeManager extends AbstractContentTypeManager { /** * Determines if the current resource should have its payload wrapped in a "data" object. * - * NOTE: the users-permissions plugin has a different API contract than regular content-types. - * It expects raw payload data without wrapping in a "data" object. - * As this is a Strapi managed plugin, we support this edge case here. + * NOTE: Some plugins (like users-permissions) have different API contracts than regular content-types. + * They expect raw payload data without wrapping in a "data" object. * * @private * @returns true if the resource should use data wrapping (regular content-types) */ private shouldWrapDataBodyAttribute(): boolean { - return !pluginsThatDoNotWrapDataAttribute.includes(this._pluginName ?? ''); + return shouldWrapData(this._pluginName); } /** diff --git a/src/content-types/constants.ts b/src/content-types/constants.ts index 1b9389d..c9ab028 100644 --- a/src/content-types/constants.ts +++ b/src/content-types/constants.ts @@ -1,22 +1,99 @@ /** - * Mapping of well-known Strapi resource names to their plugin configurations. - * This enables automatic handling of special Strapi content-types that have - * different API contracts than regular content-types. + * Registry of well-known collection-type resources in Strapi. + * These resources have different API contracts than regular content-types. */ -export const WELL_KNOWN_STRAPI_RESOURCES: Record< - string, - { plugin: { name: string; prefix: string } } -> = { - // Users from users-permissions plugin don't wrap data and have no route prefix +const WELL_KNOWN_COLLECTIONS: Record = { + /** + * Users from the users-permissions plugin. + * - Routes: /users (no plugin prefix) + * - Data wrapping: No (expects raw payloads) + */ users: { plugin: { name: 'users-permissions', prefix: '', }, + wrapsData: false, }, }; /** - * List of plugin names that do not wrap the inner payload in a "data" attribute. + * Registry of well-known single-type resources in Strapi. + * Currently empty as there are no known single-types with special API contracts. */ -export const pluginsThatDoNotWrapDataAttribute = ['users-permissions']; +const WELL_KNOWN_SINGLES: Record = { + // Currently no well-known single-types with special API contracts + // Example structure if needed in the future: + // '[some-single-type]': { + // plugin: { name: '[plugin-name]', prefix: '[plugin-prefix]' }, + // wrapsData: false, + // }, +}; + +/** + * Configuration for well-known Strapi resources that have special API contracts. + */ +export interface WellKnownResourceConfig { + /** + * Plugin configuration for the resource. + */ + plugin: { + /** + * Name of the plugin that owns this resource. + */ + name: string; + /** + * Route prefix for the plugin. + * Empty string means no prefix is used. + */ + prefix: string; + }; + /** + * Whether this resource type wraps request payloads in a "data" object. + * Regular Strapi content-types wrap data: { data: {...} } + * Some plugins (like users-permissions) expect unwrapped data: {...} + */ + wrapsData: boolean; +} + +/** + * Gets the configuration for a well-known collection resource, if it exists. + * + * @internal + * @param resource - The collection resource name to look up + * @returns The resource configuration if found, undefined otherwise + */ +export function getWellKnownCollection(resource: string): WellKnownResourceConfig | undefined { + return WELL_KNOWN_COLLECTIONS[resource]; +} + +/** + * Gets the configuration for a well-known single-type resource, if it exists. + * + * @internal + * @param resource - The single-type resource name to look up + * @returns The resource configuration if found, undefined otherwise + */ +export function getWellKnownSingle(resource: string): WellKnownResourceConfig | undefined { + return WELL_KNOWN_SINGLES[resource]; +} + +/** + * Checks if a resource should wrap data in a "data" object based on its plugin. + * + * @param pluginName - The name of the plugin, if any + * @returns true if data should be wrapped, false otherwise + */ +export function shouldWrapData(pluginName: string | undefined): boolean { + if (pluginName === undefined) { + return true; // Regular content-types wrap data + } + + // Check if this plugin is known to not wrap data + const knownPlugin = Object.values({ + ...WELL_KNOWN_COLLECTIONS, + ...WELL_KNOWN_SINGLES, + }).find((config) => config.plugin.name === pluginName); + + return knownPlugin?.wrapsData ?? true; +} diff --git a/src/content-types/index.ts b/src/content-types/index.ts index 1dd69c8..a01a5c0 100644 --- a/src/content-types/index.ts +++ b/src/content-types/index.ts @@ -1,2 +1,3 @@ export * from './single'; export * from './collection'; +export * from './constants'; diff --git a/src/content-types/single/manager.ts b/src/content-types/single/manager.ts index 4317918..f7fbe1d 100644 --- a/src/content-types/single/manager.ts +++ b/src/content-types/single/manager.ts @@ -3,6 +3,7 @@ import createDebug from 'debug'; import { HttpClient } from '../../http'; import { URLHelper } from '../../utilities'; import { AbstractContentTypeManager } from '../abstract'; +import { shouldWrapData } from '../constants'; import type * as API from '../../types/content-api'; import type { ContentTypeManagerOptions } from '../abstract'; @@ -38,6 +39,19 @@ export class SingleTypeManager extends AbstractContentTypeManager { debug('initialized a new "single" manager with %o', options); } + /** + * Determines if the current resource should have its payload wrapped in a "data" object. + * + * NOTE: Some plugins (like users-permissions) have different API contracts than regular content-types. + * They expect raw payload data without wrapping in a "data" object. + * + * @private + * @returns true if the resource should use data wrapping (regular content-types) + */ + private shouldWrapDataBodyAttribute(): boolean { + return shouldWrapData(this._pluginName); + } + /** * Retrieves the document of the specified single-type resource. * @@ -114,8 +128,8 @@ export class SingleTypeManager extends AbstractContentTypeManager { const response = await this._httpClient.put( url, - // Wrap the payload in a data object - JSON.stringify({ data }), + // Conditionally wrap the payload in a data object + JSON.stringify(this.shouldWrapDataBodyAttribute() ? { data } : data), // By default PUT requests sets the content-type to text/plain { headers: { 'Content-Type': 'application/json' } } ); diff --git a/tests/unit/client.test.ts b/tests/unit/client.test.ts index 2be1285..872c8fa 100644 --- a/tests/unit/client.test.ts +++ b/tests/unit/client.test.ts @@ -275,6 +275,33 @@ describe('Strapi', () => { expect(single).toBeInstanceOf(SingleTypeManager); expect(single).toHaveProperty('_options', { resource }); }); + + it('should support plugin option for single-types', () => { + // Arrange + const resource = 'settings'; + const customPlugin = { name: 'custom-plugin', prefix: 'custom' }; + const config = { baseURL: 'https://localhost:1337/api' } satisfies StrapiClientConfig; + + const mockValidator = new MockStrapiConfigValidator(); + const mockAuthManager = new MockAuthManager(); + + const client = new StrapiClient( + config, + mockValidator, + mockAuthManager, + mockHttpClientFactory + ); + + // Act + const single = client.single(resource, { plugin: customPlugin }); + + // Assert + expect(single).toBeInstanceOf(SingleTypeManager); + expect(single).toHaveProperty('_options', { + resource: 'settings', + plugin: customPlugin, + }); + }); }); describe('Custom Interceptors', () => { diff --git a/tests/unit/content-types/collection/single-manager.test.ts b/tests/unit/content-types/collection/single-manager.test.ts index 257a0dc..008b773 100644 --- a/tests/unit/content-types/collection/single-manager.test.ts +++ b/tests/unit/content-types/collection/single-manager.test.ts @@ -140,4 +140,71 @@ describe('SingleTypeManager CRUD Methods', () => { }); }); }); + + describe('Plugin Support', () => { + beforeEach(() => { + jest + .spyOn(MockHttpClient.prototype, 'request') + .mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ id: 1, setting: 'value' }), { status: 200 }) + ) + ); + }); + + it('should NOT wrap data when plugin is set to "users-permissions"', async () => { + // Arrange + const settingsManager = new SingleTypeManager( + { resource: 'settings', plugin: { name: 'users-permissions', prefix: '' } }, + mockHttpClient + ); + const payload = { setting1: 'value1', setting2: 'value2' }; + + // Act + await settingsManager.update(payload); + + // Assert - Should send raw payload without data wrapping + expect(mockHttpClient.request).toHaveBeenCalledWith('/settings', { + method: 'PUT', + body: JSON.stringify(payload), // No { data: payload } wrapping + headers: { + 'Content-Type': 'application/json', + }, + }); + }); + + it('should wrap data for regular single-types', async () => { + // Arrange + const homepageManager = new SingleTypeManager({ resource: 'homepage' }, mockHttpClient); + const payload = { title: 'Home', content: 'Welcome' }; + + // Act + await homepageManager.update(payload); + + // Assert - Should wrap payload in data object + expect(mockHttpClient.request).toHaveBeenCalledWith('/homepage', { + method: 'PUT', + body: JSON.stringify({ data: payload }), + headers: { + 'Content-Type': 'application/json', + }, + }); + }); + + it('should support plugin route prefixing', async () => { + // Arrange + const settingsManager = new SingleTypeManager( + { resource: 'settings', plugin: { name: 'my-plugin' } }, + mockHttpClient + ); + + // Act + await settingsManager.find(); + + // Assert - Should prefix route with plugin name + expect(mockHttpClient.request).toHaveBeenCalledWith('/my-plugin/settings', { + method: 'GET', + }); + }); + }); });