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
18 changes: 12 additions & 6 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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<ContentTypeManagerOptions, 'path' | 'plugin'>;
export type SingleCollectionOptions = Pick<ContentTypeManagerOptions, 'path'>;
export type SingleCollectionOptions = Pick<ContentTypeManagerOptions, 'path' | 'plugin'>;
9 changes: 4 additions & 5 deletions src/content-types/collection/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}

/**
Expand Down
97 changes: 87 additions & 10 deletions src/content-types/constants.ts
Original file line number Diff line number Diff line change
@@ -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<string, WellKnownResourceConfig> = {
/**
* 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<string, WellKnownResourceConfig> = {
// 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,
// },
Comment on lines +25 to +30
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I couldn't come up with any use case here. Ideas?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

There are none that I know of

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Me either; pInging @Convly in case he's aware of any :)

};

/**
* 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;
}
1 change: 1 addition & 0 deletions src/content-types/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './single';
export * from './collection';
export * from './constants';
18 changes: 16 additions & 2 deletions src/content-types/single/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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' } }
);
Expand Down
27 changes: 27 additions & 0 deletions tests/unit/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
67 changes: 67 additions & 0 deletions tests/unit/content-types/collection/single-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
});
});
});