Skip to content

Commit e3746af

Browse files
authored
feat(client.single): support plugin config argument (#106)
1 parent a18818f commit e3746af

File tree

7 files changed

+214
-23
lines changed

7 files changed

+214
-23
lines changed

src/client.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import createDebug from 'debug';
22

33
import { AuthManager } from './auth';
44
import { CollectionTypeManager, SingleTypeManager } from './content-types';
5-
import { WELL_KNOWN_STRAPI_RESOURCES } from './content-types/constants';
5+
import { getWellKnownCollection, getWellKnownSingle } from './content-types/constants';
66
import { StrapiError, StrapiInitializationError } from './errors';
77
import { FilesManager } from './files';
88
import { HttpClient } from './http';
@@ -363,9 +363,10 @@ export class StrapiClient {
363363
collection(resource: string, options: ClientCollectionOptions = {}) {
364364
const { path, plugin } = options;
365365

366-
// Auto-detect well-known Strapi resources and apply their plugin configuration
366+
// Auto-detect well-known collection resources and apply their plugin configuration
367367
// if no explicit plugin option is provided
368-
const effectivePlugin = plugin ?? WELL_KNOWN_STRAPI_RESOURCES[resource]?.plugin ?? undefined;
368+
const wellKnownConfig = getWellKnownCollection(resource);
369+
const effectivePlugin = plugin ?? wellKnownConfig?.plugin ?? undefined;
369370

370371
return new CollectionTypeManager({ resource, path, plugin: effectivePlugin }, this._httpClient);
371372
}
@@ -407,12 +408,17 @@ export class StrapiClient {
407408
* @see StrapiClient
408409
*/
409410
single(resource: string, options: SingleCollectionOptions = {}) {
410-
const { path } = options;
411+
const { path, plugin } = options;
412+
413+
// Auto-detect well-known single-type resources and apply their plugin configuration
414+
// if no explicit plugin option is provided
415+
const wellKnownConfig = getWellKnownSingle(resource);
416+
const effectivePlugin = plugin ?? wellKnownConfig?.plugin ?? undefined;
411417

412-
return new SingleTypeManager({ resource, path }, this._httpClient);
418+
return new SingleTypeManager({ resource, path, plugin: effectivePlugin }, this._httpClient);
413419
}
414420
}
415421

416422
// Local Client Types
417423
export type ClientCollectionOptions = Pick<ContentTypeManagerOptions, 'path' | 'plugin'>;
418-
export type SingleCollectionOptions = Pick<ContentTypeManagerOptions, 'path'>;
424+
export type SingleCollectionOptions = Pick<ContentTypeManagerOptions, 'path' | 'plugin'>;

src/content-types/collection/manager.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import createDebug from 'debug';
33
import { HttpClient } from '../../http';
44
import { URLHelper } from '../../utilities';
55
import { AbstractContentTypeManager } from '../abstract';
6-
import { pluginsThatDoNotWrapDataAttribute } from '../constants';
6+
import { shouldWrapData } from '../constants';
77

88
import type * as API from '../../types/content-api';
99
import type { ContentTypeManagerOptions } from '../abstract';
@@ -42,15 +42,14 @@ export class CollectionTypeManager extends AbstractContentTypeManager {
4242
/**
4343
* Determines if the current resource should have its payload wrapped in a "data" object.
4444
*
45-
* NOTE: the users-permissions plugin has a different API contract than regular content-types.
46-
* It expects raw payload data without wrapping in a "data" object.
47-
* As this is a Strapi managed plugin, we support this edge case here.
45+
* NOTE: Some plugins (like users-permissions) have different API contracts than regular content-types.
46+
* They expect raw payload data without wrapping in a "data" object.
4847
*
4948
* @private
5049
* @returns true if the resource should use data wrapping (regular content-types)
5150
*/
5251
private shouldWrapDataBodyAttribute(): boolean {
53-
return !pluginsThatDoNotWrapDataAttribute.includes(this._pluginName ?? '');
52+
return shouldWrapData(this._pluginName);
5453
}
5554

5655
/**

src/content-types/constants.ts

Lines changed: 87 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,99 @@
11
/**
2-
* Mapping of well-known Strapi resource names to their plugin configurations.
3-
* This enables automatic handling of special Strapi content-types that have
4-
* different API contracts than regular content-types.
2+
* Registry of well-known collection-type resources in Strapi.
3+
* These resources have different API contracts than regular content-types.
54
*/
6-
export const WELL_KNOWN_STRAPI_RESOURCES: Record<
7-
string,
8-
{ plugin: { name: string; prefix: string } }
9-
> = {
10-
// Users from users-permissions plugin don't wrap data and have no route prefix
5+
const WELL_KNOWN_COLLECTIONS: Record<string, WellKnownResourceConfig> = {
6+
/**
7+
* Users from the users-permissions plugin.
8+
* - Routes: /users (no plugin prefix)
9+
* - Data wrapping: No (expects raw payloads)
10+
*/
1111
users: {
1212
plugin: {
1313
name: 'users-permissions',
1414
prefix: '',
1515
},
16+
wrapsData: false,
1617
},
1718
};
1819

1920
/**
20-
* List of plugin names that do not wrap the inner payload in a "data" attribute.
21+
* Registry of well-known single-type resources in Strapi.
22+
* Currently empty as there are no known single-types with special API contracts.
2123
*/
22-
export const pluginsThatDoNotWrapDataAttribute = ['users-permissions'];
24+
const WELL_KNOWN_SINGLES: Record<string, WellKnownResourceConfig> = {
25+
// Currently no well-known single-types with special API contracts
26+
// Example structure if needed in the future:
27+
// '[some-single-type]': {
28+
// plugin: { name: '[plugin-name]', prefix: '[plugin-prefix]' },
29+
// wrapsData: false,
30+
// },
31+
};
32+
33+
/**
34+
* Configuration for well-known Strapi resources that have special API contracts.
35+
*/
36+
export interface WellKnownResourceConfig {
37+
/**
38+
* Plugin configuration for the resource.
39+
*/
40+
plugin: {
41+
/**
42+
* Name of the plugin that owns this resource.
43+
*/
44+
name: string;
45+
/**
46+
* Route prefix for the plugin.
47+
* Empty string means no prefix is used.
48+
*/
49+
prefix: string;
50+
};
51+
/**
52+
* Whether this resource type wraps request payloads in a "data" object.
53+
* Regular Strapi content-types wrap data: { data: {...} }
54+
* Some plugins (like users-permissions) expect unwrapped data: {...}
55+
*/
56+
wrapsData: boolean;
57+
}
58+
59+
/**
60+
* Gets the configuration for a well-known collection resource, if it exists.
61+
*
62+
* @internal
63+
* @param resource - The collection resource name to look up
64+
* @returns The resource configuration if found, undefined otherwise
65+
*/
66+
export function getWellKnownCollection(resource: string): WellKnownResourceConfig | undefined {
67+
return WELL_KNOWN_COLLECTIONS[resource];
68+
}
69+
70+
/**
71+
* Gets the configuration for a well-known single-type resource, if it exists.
72+
*
73+
* @internal
74+
* @param resource - The single-type resource name to look up
75+
* @returns The resource configuration if found, undefined otherwise
76+
*/
77+
export function getWellKnownSingle(resource: string): WellKnownResourceConfig | undefined {
78+
return WELL_KNOWN_SINGLES[resource];
79+
}
80+
81+
/**
82+
* Checks if a resource should wrap data in a "data" object based on its plugin.
83+
*
84+
* @param pluginName - The name of the plugin, if any
85+
* @returns true if data should be wrapped, false otherwise
86+
*/
87+
export function shouldWrapData(pluginName: string | undefined): boolean {
88+
if (pluginName === undefined) {
89+
return true; // Regular content-types wrap data
90+
}
91+
92+
// Check if this plugin is known to not wrap data
93+
const knownPlugin = Object.values({
94+
...WELL_KNOWN_COLLECTIONS,
95+
...WELL_KNOWN_SINGLES,
96+
}).find((config) => config.plugin.name === pluginName);
97+
98+
return knownPlugin?.wrapsData ?? true;
99+
}

src/content-types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './single';
22
export * from './collection';
3+
export * from './constants';

src/content-types/single/manager.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import createDebug from 'debug';
33
import { HttpClient } from '../../http';
44
import { URLHelper } from '../../utilities';
55
import { AbstractContentTypeManager } from '../abstract';
6+
import { shouldWrapData } from '../constants';
67

78
import type * as API from '../../types/content-api';
89
import type { ContentTypeManagerOptions } from '../abstract';
@@ -38,6 +39,19 @@ export class SingleTypeManager extends AbstractContentTypeManager {
3839
debug('initialized a new "single" manager with %o', options);
3940
}
4041

42+
/**
43+
* Determines if the current resource should have its payload wrapped in a "data" object.
44+
*
45+
* NOTE: Some plugins (like users-permissions) have different API contracts than regular content-types.
46+
* They expect raw payload data without wrapping in a "data" object.
47+
*
48+
* @private
49+
* @returns true if the resource should use data wrapping (regular content-types)
50+
*/
51+
private shouldWrapDataBodyAttribute(): boolean {
52+
return shouldWrapData(this._pluginName);
53+
}
54+
4155
/**
4256
* Retrieves the document of the specified single-type resource.
4357
*
@@ -114,8 +128,8 @@ export class SingleTypeManager extends AbstractContentTypeManager {
114128

115129
const response = await this._httpClient.put(
116130
url,
117-
// Wrap the payload in a data object
118-
JSON.stringify({ data }),
131+
// Conditionally wrap the payload in a data object
132+
JSON.stringify(this.shouldWrapDataBodyAttribute() ? { data } : data),
119133
// By default PUT requests sets the content-type to text/plain
120134
{ headers: { 'Content-Type': 'application/json' } }
121135
);

tests/unit/client.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,33 @@ describe('Strapi', () => {
275275
expect(single).toBeInstanceOf(SingleTypeManager);
276276
expect(single).toHaveProperty('_options', { resource });
277277
});
278+
279+
it('should support plugin option for single-types', () => {
280+
// Arrange
281+
const resource = 'settings';
282+
const customPlugin = { name: 'custom-plugin', prefix: 'custom' };
283+
const config = { baseURL: 'https://localhost:1337/api' } satisfies StrapiClientConfig;
284+
285+
const mockValidator = new MockStrapiConfigValidator();
286+
const mockAuthManager = new MockAuthManager();
287+
288+
const client = new StrapiClient(
289+
config,
290+
mockValidator,
291+
mockAuthManager,
292+
mockHttpClientFactory
293+
);
294+
295+
// Act
296+
const single = client.single(resource, { plugin: customPlugin });
297+
298+
// Assert
299+
expect(single).toBeInstanceOf(SingleTypeManager);
300+
expect(single).toHaveProperty('_options', {
301+
resource: 'settings',
302+
plugin: customPlugin,
303+
});
304+
});
278305
});
279306

280307
describe('Custom Interceptors', () => {

tests/unit/content-types/collection/single-manager.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,4 +140,71 @@ describe('SingleTypeManager CRUD Methods', () => {
140140
});
141141
});
142142
});
143+
144+
describe('Plugin Support', () => {
145+
beforeEach(() => {
146+
jest
147+
.spyOn(MockHttpClient.prototype, 'request')
148+
.mockImplementation(() =>
149+
Promise.resolve(
150+
new Response(JSON.stringify({ id: 1, setting: 'value' }), { status: 200 })
151+
)
152+
);
153+
});
154+
155+
it('should NOT wrap data when plugin is set to "users-permissions"', async () => {
156+
// Arrange
157+
const settingsManager = new SingleTypeManager(
158+
{ resource: 'settings', plugin: { name: 'users-permissions', prefix: '' } },
159+
mockHttpClient
160+
);
161+
const payload = { setting1: 'value1', setting2: 'value2' };
162+
163+
// Act
164+
await settingsManager.update(payload);
165+
166+
// Assert - Should send raw payload without data wrapping
167+
expect(mockHttpClient.request).toHaveBeenCalledWith('/settings', {
168+
method: 'PUT',
169+
body: JSON.stringify(payload), // No { data: payload } wrapping
170+
headers: {
171+
'Content-Type': 'application/json',
172+
},
173+
});
174+
});
175+
176+
it('should wrap data for regular single-types', async () => {
177+
// Arrange
178+
const homepageManager = new SingleTypeManager({ resource: 'homepage' }, mockHttpClient);
179+
const payload = { title: 'Home', content: 'Welcome' };
180+
181+
// Act
182+
await homepageManager.update(payload);
183+
184+
// Assert - Should wrap payload in data object
185+
expect(mockHttpClient.request).toHaveBeenCalledWith('/homepage', {
186+
method: 'PUT',
187+
body: JSON.stringify({ data: payload }),
188+
headers: {
189+
'Content-Type': 'application/json',
190+
},
191+
});
192+
});
193+
194+
it('should support plugin route prefixing', async () => {
195+
// Arrange
196+
const settingsManager = new SingleTypeManager(
197+
{ resource: 'settings', plugin: { name: 'my-plugin' } },
198+
mockHttpClient
199+
);
200+
201+
// Act
202+
await settingsManager.find();
203+
204+
// Assert - Should prefix route with plugin name
205+
expect(mockHttpClient.request).toHaveBeenCalledWith('/my-plugin/settings', {
206+
method: 'GET',
207+
});
208+
});
209+
});
143210
});

0 commit comments

Comments
 (0)