@devcustrom/strapi-plugin-api-select 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +64 -0
- package/LICENSE +21 -0
- package/README.md +266 -0
- package/admin/src/index.js +242 -0
- package/admin/src/plugin/ApiSelectInput.jsx +380 -0
- package/package.json +59 -0
- package/server/controllers/index.js +5 -0
- package/server/controllers/proxy.js +87 -0
- package/server/index.js +9 -0
- package/server/register.js +8 -0
- package/server/routes/index.js +7 -0
- package/strapi-admin.js +1 -0
- package/strapi-server.js +1 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [1.0.1] - 2025-08-21
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- π Fixed plugin name inconsistency causing custom field registration error
|
|
13
|
+
- π§ Updated field name from "custom-strapi-select" to "api-select" to match package.json
|
|
14
|
+
- π Updated all i18n keys to use consistent "api-select" prefix
|
|
15
|
+
|
|
16
|
+
## [1.0.0] - 2025-08-21
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- π Initial release of Strapi Plugin API Select
|
|
21
|
+
- β¨ Custom field for API-driven select dropdowns
|
|
22
|
+
- π Support for external API integration
|
|
23
|
+
- π‘ HTTP GET and POST request methods
|
|
24
|
+
- π Custom headers and authentication support
|
|
25
|
+
- π Request payload configuration for POST requests
|
|
26
|
+
- πΊοΈ Flexible response data path mapping
|
|
27
|
+
- ποΈ Custom response field mapping configuration
|
|
28
|
+
- π Proxy mode for private APIs with CORS bypass
|
|
29
|
+
- π Basic SSRF protection for API URLs
|
|
30
|
+
- π Multi-language content support
|
|
31
|
+
- π± Single and multiple selection modes
|
|
32
|
+
- π¨ Strapi v5 design system integration
|
|
33
|
+
- π Real-time option loading
|
|
34
|
+
- π Field descriptions and help text
|
|
35
|
+
- β‘ Performance optimized with caching
|
|
36
|
+
- π Comprehensive error handling
|
|
37
|
+
- π Debug logging for troubleshooting
|
|
38
|
+
|
|
39
|
+
### Features
|
|
40
|
+
|
|
41
|
+
- **Dynamic Select Fields**: Create select fields that fetch options from any REST API
|
|
42
|
+
- **Flexible HTTP Methods**: Support for both GET and POST requests with custom payloads
|
|
43
|
+
- **Authentication Support**: Add custom headers for API authentication
|
|
44
|
+
- **Response Mapping**: Handle diverse API response structures with custom field mapping
|
|
45
|
+
- **Proxy Support**: Built-in proxy for private APIs and CORS handling
|
|
46
|
+
- **Multilingual Ready**: Works with Strapi's internationalization features
|
|
47
|
+
- **Production Ready**: Includes error handling, validation, and performance optimizations
|
|
48
|
+
|
|
49
|
+
### Technical
|
|
50
|
+
|
|
51
|
+
- Built for Strapi v5
|
|
52
|
+
- ES6/CommonJS module compatibility
|
|
53
|
+
- React-based admin UI
|
|
54
|
+
- TypeScript support
|
|
55
|
+
- Node.js 18+ compatibility
|
|
56
|
+
- Modern build system integration
|
|
57
|
+
|
|
58
|
+
### Documentation
|
|
59
|
+
|
|
60
|
+
- Comprehensive README with examples
|
|
61
|
+
- API response format examples
|
|
62
|
+
- Configuration guide
|
|
63
|
+
- Troubleshooting section
|
|
64
|
+
- MIT License
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# Strapi Plugin API Select
|
|
2
|
+
|
|
3
|
+
[](https://badge.fury.io/js/@devcustrom/strapi-plugin-api-select)
|
|
4
|
+
[](https://npmjs.org/package/@devcustrom/strapi-plugin-api-select)
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
|
|
7
|
+
A powerful **Strapi v5 plugin** that provides API-driven select fields with support for GET/POST requests, custom headers, flexible response mapping, and multilingual content.
|
|
8
|
+
|
|
9
|
+
## β¨ Features
|
|
10
|
+
|
|
11
|
+
- π **External API Integration** - Fetch select options from any REST API
|
|
12
|
+
- π‘ **HTTP Methods** - Support for both GET and POST requests
|
|
13
|
+
- π **Authentication** - Custom headers and API token support
|
|
14
|
+
- π **Request Payloads** - Configure JSON payloads for POST requests
|
|
15
|
+
- πΊοΈ **Flexible Mapping** - Handle diverse API response structures
|
|
16
|
+
- π **Proxy Mode** - Built-in proxy for private APIs and CORS bypass
|
|
17
|
+
- π **Multilingual** - Works with Strapi's i18n features
|
|
18
|
+
- π± **Selection Modes** - Single or multiple selection
|
|
19
|
+
- β‘ **Performance** - Optimized with error handling and caching
|
|
20
|
+
|
|
21
|
+
## π Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install @devcustrom/strapi-plugin-api-select
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## βοΈ Configuration
|
|
28
|
+
|
|
29
|
+
Add the plugin to your `config/plugins.js` file:
|
|
30
|
+
|
|
31
|
+
```javascript
|
|
32
|
+
module.exports = {
|
|
33
|
+
// ...
|
|
34
|
+
"api-select": {
|
|
35
|
+
enabled: true,
|
|
36
|
+
},
|
|
37
|
+
// ...
|
|
38
|
+
};
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## π Usage
|
|
42
|
+
|
|
43
|
+
### 1. Add Field to Content Type
|
|
44
|
+
|
|
45
|
+
1. Go to **Content-Type Builder** in your Strapi admin
|
|
46
|
+
2. Select your content type (e.g., Article, Product)
|
|
47
|
+
3. Click **Add another field**
|
|
48
|
+
4. Choose **Custom** β **API Select**
|
|
49
|
+
5. Configure your field options
|
|
50
|
+
|
|
51
|
+
### 2. Field Configuration
|
|
52
|
+
|
|
53
|
+
#### Basic Settings
|
|
54
|
+
|
|
55
|
+
- **Options API URL**: The endpoint that returns your options
|
|
56
|
+
- **Label Key**: Field to use for display text (default: `name`)
|
|
57
|
+
- **Value Key**: Field to use for stored value (default: `id`)
|
|
58
|
+
- **Select Mode**: Single or Multiple selection
|
|
59
|
+
- **Auth Mode**: Public API or Proxy for private APIs
|
|
60
|
+
|
|
61
|
+
#### Advanced Settings
|
|
62
|
+
|
|
63
|
+
- **HTTP Method**: GET or POST (default: `GET`)
|
|
64
|
+
- **Request Payload**: JSON payload for POST requests
|
|
65
|
+
- **Custom Headers**: Additional headers as JSON object
|
|
66
|
+
- **Response Data Path**: Path to array in response (e.g., `data`, `results`)
|
|
67
|
+
- **Response Mapping**: Custom field mapping for complex responses
|
|
68
|
+
|
|
69
|
+
## π Examples
|
|
70
|
+
|
|
71
|
+
### Basic GET Request
|
|
72
|
+
|
|
73
|
+
```javascript
|
|
74
|
+
// API URL: https://api.example.com/categories
|
|
75
|
+
// Response:
|
|
76
|
+
[
|
|
77
|
+
{ id: 1, name: "Technology" },
|
|
78
|
+
{ id: 2, name: "Science" },
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
// Configuration:
|
|
82
|
+
// - Label Key: name
|
|
83
|
+
// - Value Key: id
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### POST Request with Payload
|
|
87
|
+
|
|
88
|
+
```javascript
|
|
89
|
+
// Configuration:
|
|
90
|
+
// - HTTP Method: POST
|
|
91
|
+
// - Request Payload:
|
|
92
|
+
{
|
|
93
|
+
"filters": {"status": "active"},
|
|
94
|
+
"limit": 100
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// - Custom Headers:
|
|
98
|
+
{
|
|
99
|
+
"Authorization": "Bearer your-token",
|
|
100
|
+
"X-API-Key": "your-api-key"
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Complex Response Mapping
|
|
105
|
+
|
|
106
|
+
```javascript
|
|
107
|
+
// API Response:
|
|
108
|
+
{
|
|
109
|
+
"success": true,
|
|
110
|
+
"data": {
|
|
111
|
+
"users": [
|
|
112
|
+
{
|
|
113
|
+
"userId": 1,
|
|
114
|
+
"profile": {"displayName": "John Doe"},
|
|
115
|
+
"department": {"name": "Engineering"}
|
|
116
|
+
}
|
|
117
|
+
]
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Configuration:
|
|
122
|
+
// - Response Data Path: data.users
|
|
123
|
+
// - Response Mapping:
|
|
124
|
+
{
|
|
125
|
+
"value": "userId",
|
|
126
|
+
"label": "profile.displayName",
|
|
127
|
+
"group": "department.name"
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Different Response Formats
|
|
132
|
+
|
|
133
|
+
The plugin automatically handles various API response structures:
|
|
134
|
+
|
|
135
|
+
#### Direct Array
|
|
136
|
+
|
|
137
|
+
```json
|
|
138
|
+
[
|
|
139
|
+
{ "id": 1, "name": "Option 1" },
|
|
140
|
+
{ "id": 2, "name": "Option 2" }
|
|
141
|
+
]
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
#### Nested Data
|
|
145
|
+
|
|
146
|
+
```json
|
|
147
|
+
{
|
|
148
|
+
"data": [
|
|
149
|
+
{ "id": 1, "title": "Option 1" },
|
|
150
|
+
{ "id": 2, "title": "Option 2" }
|
|
151
|
+
]
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
#### Complex Structure
|
|
156
|
+
|
|
157
|
+
```json
|
|
158
|
+
{
|
|
159
|
+
"response": {
|
|
160
|
+
"items": [{ "uuid": "abc-123", "label": "Custom Option" }]
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## π Security Features
|
|
166
|
+
|
|
167
|
+
- **SSRF Protection**: Validates API URLs to prevent server-side request forgery
|
|
168
|
+
- **Proxy Mode**: Routes requests through Strapi backend for private APIs
|
|
169
|
+
- **Environment Variables**: Support for API tokens via environment variables
|
|
170
|
+
|
|
171
|
+
Set your API token:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
CUSTOM_STRAPI_SELECT_TOKEN=your_api_token_here
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## π Proxy Mode
|
|
178
|
+
|
|
179
|
+
For private APIs or to bypass CORS issues, use proxy mode:
|
|
180
|
+
|
|
181
|
+
1. Set **Auth Mode** to "Proxy"
|
|
182
|
+
2. Requests will be routed through your Strapi backend
|
|
183
|
+
3. Configure authentication via environment variables
|
|
184
|
+
|
|
185
|
+
## ποΈ Field Types Supported
|
|
186
|
+
|
|
187
|
+
### Single Selection
|
|
188
|
+
|
|
189
|
+
Returns a single value:
|
|
190
|
+
|
|
191
|
+
```javascript
|
|
192
|
+
"selected_option_id";
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### Multiple Selection
|
|
196
|
+
|
|
197
|
+
Returns an array of values:
|
|
198
|
+
|
|
199
|
+
```javascript
|
|
200
|
+
["option_1", "option_2", "option_3"];
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## π§ Development
|
|
204
|
+
|
|
205
|
+
### Local Development
|
|
206
|
+
|
|
207
|
+
1. Clone the repository
|
|
208
|
+
2. Install dependencies: `npm install`
|
|
209
|
+
3. Link locally: `npm link`
|
|
210
|
+
4. In your Strapi project: `npm link @devcustrom/strapi-plugin-api-select`
|
|
211
|
+
5. Restart Strapi
|
|
212
|
+
|
|
213
|
+
### Testing
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
npm test
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
## π API Reference
|
|
220
|
+
|
|
221
|
+
### Server Routes
|
|
222
|
+
|
|
223
|
+
- `GET /api/api-select/fetch` - Proxy endpoint for fetching external API data
|
|
224
|
+
|
|
225
|
+
### Query Parameters
|
|
226
|
+
|
|
227
|
+
- `api` - External API URL
|
|
228
|
+
- `labelKey` - Field name for option labels
|
|
229
|
+
- `valueKey` - Field name for option values
|
|
230
|
+
- `method` - HTTP method (GET/POST)
|
|
231
|
+
- `payload` - JSON payload for POST requests
|
|
232
|
+
- `headers` - Custom headers as JSON string
|
|
233
|
+
|
|
234
|
+
## π€ Contributing
|
|
235
|
+
|
|
236
|
+
We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details.
|
|
237
|
+
|
|
238
|
+
1. Fork the repository
|
|
239
|
+
2. Create your feature branch: `git checkout -b feature/amazing-feature`
|
|
240
|
+
3. Commit your changes: `git commit -m 'Add amazing feature'`
|
|
241
|
+
4. Push to the branch: `git push origin feature/amazing-feature`
|
|
242
|
+
5. Open a Pull Request
|
|
243
|
+
|
|
244
|
+
## π Changelog
|
|
245
|
+
|
|
246
|
+
See [CHANGELOG.md](CHANGELOG.md) for a list of changes.
|
|
247
|
+
|
|
248
|
+
## π License
|
|
249
|
+
|
|
250
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
251
|
+
|
|
252
|
+
## π Support
|
|
253
|
+
|
|
254
|
+
- π [Documentation](https://github.com/devcustrom/strapi-plugin-api-select#readme)
|
|
255
|
+
- π [Issue Tracker](https://github.com/devcustrom/strapi-plugin-api-select/issues)
|
|
256
|
+
- π¬ [Strapi Discord](https://discord.strapi.io) - Ask in #plugins channel
|
|
257
|
+
|
|
258
|
+
## π Acknowledgments
|
|
259
|
+
|
|
260
|
+
- Built for [Strapi v5](https://strapi.io)
|
|
261
|
+
- Inspired by the need for dynamic content in headless CMS
|
|
262
|
+
- Thanks to the Strapi community for feedback and contributions
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
Made with β€οΈ for the Strapi community
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
register(app) {
|
|
3
|
+
app.customFields.register({
|
|
4
|
+
name: "api-select",
|
|
5
|
+
pluginId: "api-select",
|
|
6
|
+
type: "json",
|
|
7
|
+
intlLabel: {
|
|
8
|
+
id: "api-select.label",
|
|
9
|
+
defaultMessage: "API Select",
|
|
10
|
+
},
|
|
11
|
+
intlDescription: {
|
|
12
|
+
id: "api-select.desc",
|
|
13
|
+
defaultMessage: "Select options from a remote API",
|
|
14
|
+
},
|
|
15
|
+
icon: () => "π",
|
|
16
|
+
components: {
|
|
17
|
+
Input: async () =>
|
|
18
|
+
import("./plugin/ApiSelectInput.jsx").then((m) => ({
|
|
19
|
+
default: m.default,
|
|
20
|
+
})),
|
|
21
|
+
},
|
|
22
|
+
options: {
|
|
23
|
+
base: [
|
|
24
|
+
{
|
|
25
|
+
name: "options.optionsApi",
|
|
26
|
+
type: "text",
|
|
27
|
+
intlLabel: {
|
|
28
|
+
id: "api-select.optionsApi.label",
|
|
29
|
+
defaultMessage: "Options API URL",
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: "options.optionLabelKey",
|
|
34
|
+
type: "text",
|
|
35
|
+
defaultValue: "name",
|
|
36
|
+
intlLabel: {
|
|
37
|
+
id: "api-select.optionLabelKey.label",
|
|
38
|
+
defaultMessage: "Label key",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: "options.optionValueKey",
|
|
43
|
+
type: "text",
|
|
44
|
+
defaultValue: "id",
|
|
45
|
+
intlLabel: {
|
|
46
|
+
id: "api-select.optionValueKey.label",
|
|
47
|
+
defaultMessage: "Value key",
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: "options.selectMode",
|
|
52
|
+
type: "select",
|
|
53
|
+
defaultValue: "multiple",
|
|
54
|
+
options: [
|
|
55
|
+
{
|
|
56
|
+
key: "single",
|
|
57
|
+
value: "single",
|
|
58
|
+
metadatas: {
|
|
59
|
+
intlLabel: {
|
|
60
|
+
id: "api-select.selectMode.single",
|
|
61
|
+
defaultMessage: "Single",
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
key: "multiple",
|
|
67
|
+
value: "multiple",
|
|
68
|
+
metadatas: {
|
|
69
|
+
intlLabel: {
|
|
70
|
+
id: "api-select.selectMode.multiple",
|
|
71
|
+
defaultMessage: "Multiple",
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
intlLabel: {
|
|
77
|
+
id: "api-select.selectMode.label",
|
|
78
|
+
defaultMessage: "Select mode",
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: "options.authMode",
|
|
83
|
+
type: "select",
|
|
84
|
+
defaultValue: "public",
|
|
85
|
+
options: [
|
|
86
|
+
{
|
|
87
|
+
key: "public",
|
|
88
|
+
value: "public",
|
|
89
|
+
metadatas: {
|
|
90
|
+
intlLabel: {
|
|
91
|
+
id: "api-select.authMode.public",
|
|
92
|
+
defaultMessage: "Public",
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
key: "proxy",
|
|
98
|
+
value: "proxy",
|
|
99
|
+
metadatas: {
|
|
100
|
+
intlLabel: {
|
|
101
|
+
id: "api-select.authMode.proxy",
|
|
102
|
+
defaultMessage: "Proxy",
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
intlLabel: {
|
|
108
|
+
id: "api-select.authMode.label",
|
|
109
|
+
defaultMessage: "Auth mode",
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: "options.placeholder",
|
|
114
|
+
type: "text",
|
|
115
|
+
intlLabel: {
|
|
116
|
+
id: "api-select.placeholder.label",
|
|
117
|
+
defaultMessage: "Placeholder",
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: "options.httpMethod",
|
|
122
|
+
type: "select",
|
|
123
|
+
defaultValue: "GET",
|
|
124
|
+
options: [
|
|
125
|
+
{
|
|
126
|
+
key: "GET",
|
|
127
|
+
value: "GET",
|
|
128
|
+
metadatas: {
|
|
129
|
+
intlLabel: {
|
|
130
|
+
id: "api-select.httpMethod.get",
|
|
131
|
+
defaultMessage: "GET",
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
key: "POST",
|
|
137
|
+
value: "POST",
|
|
138
|
+
metadatas: {
|
|
139
|
+
intlLabel: {
|
|
140
|
+
id: "api-select.httpMethod.post",
|
|
141
|
+
defaultMessage: "POST",
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
intlLabel: {
|
|
147
|
+
id: "api-select.httpMethod.label",
|
|
148
|
+
defaultMessage: "HTTP Method",
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
name: "options.requestPayload",
|
|
153
|
+
type: "textarea",
|
|
154
|
+
intlLabel: {
|
|
155
|
+
id: "api-select.requestPayload.label",
|
|
156
|
+
defaultMessage: "Request Payload (JSON)",
|
|
157
|
+
},
|
|
158
|
+
description: {
|
|
159
|
+
id: "api-select.requestPayload.description",
|
|
160
|
+
defaultMessage:
|
|
161
|
+
"JSON payload for POST requests. Leave empty for GET requests.",
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
name: "options.customHeaders",
|
|
166
|
+
type: "textarea",
|
|
167
|
+
intlLabel: {
|
|
168
|
+
id: "api-select.customHeaders.label",
|
|
169
|
+
defaultMessage: "Custom Headers (JSON)",
|
|
170
|
+
},
|
|
171
|
+
description: {
|
|
172
|
+
id: "api-select.customHeaders.description",
|
|
173
|
+
defaultMessage:
|
|
174
|
+
'Additional headers as JSON object. Example: {"Authorization": "Bearer token"}',
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
name: "options.responseDataPath",
|
|
179
|
+
type: "text",
|
|
180
|
+
intlLabel: {
|
|
181
|
+
id: "api-select.responseDataPath.label",
|
|
182
|
+
defaultMessage: "Response Data Path",
|
|
183
|
+
},
|
|
184
|
+
description: {
|
|
185
|
+
id: "api-select.responseDataPath.description",
|
|
186
|
+
defaultMessage:
|
|
187
|
+
"Path to array in response. Examples: 'data', 'results', 'items', 'data.users'. Leave empty if response is direct array.",
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
name: "options.responseMappingConfig",
|
|
192
|
+
type: "textarea",
|
|
193
|
+
intlLabel: {
|
|
194
|
+
id: "api-select.responseMappingConfig.label",
|
|
195
|
+
defaultMessage: "Response Mapping (JSON)",
|
|
196
|
+
},
|
|
197
|
+
description: {
|
|
198
|
+
id: "api-select.responseMappingConfig.description",
|
|
199
|
+
defaultMessage:
|
|
200
|
+
'Custom mapping for complex responses. Example: {"value": "id", "label": "name", "group": "category.name"}',
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
// {
|
|
204
|
+
// sectionTitle: {
|
|
205
|
+
// id: "api-select.icons.section",
|
|
206
|
+
// defaultMessage: "Icon Preview",
|
|
207
|
+
// },
|
|
208
|
+
// items: [
|
|
209
|
+
// {
|
|
210
|
+
// name: "options.enableIcons",
|
|
211
|
+
// type: "checkbox",
|
|
212
|
+
// defaultValue: false,
|
|
213
|
+
// intlLabel: {
|
|
214
|
+
// id: "api-select.enableIcons.label",
|
|
215
|
+
// defaultMessage: "Show icons",
|
|
216
|
+
// },
|
|
217
|
+
// description: {
|
|
218
|
+
// id: "api-select.enableIcons.description",
|
|
219
|
+
// defaultMessage: "Display icons next to options",
|
|
220
|
+
// },
|
|
221
|
+
// },
|
|
222
|
+
// {
|
|
223
|
+
// name: "options.iconUrlKey",
|
|
224
|
+
// type: "text",
|
|
225
|
+
// defaultValue: "iconUrl",
|
|
226
|
+
// intlLabel: {
|
|
227
|
+
// id: "api-select.iconUrlKey.label",
|
|
228
|
+
// defaultMessage: "Icon URL field",
|
|
229
|
+
// },
|
|
230
|
+
// description: {
|
|
231
|
+
// id: "api-select.iconUrlKey.description",
|
|
232
|
+
// defaultMessage:
|
|
233
|
+
// "Field in API response that contains the full icon URL. Example: 'icon', 'image', 'svg'",
|
|
234
|
+
// },
|
|
235
|
+
// },
|
|
236
|
+
// ],
|
|
237
|
+
// },
|
|
238
|
+
],
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
},
|
|
242
|
+
};
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import PropTypes from "prop-types";
|
|
3
|
+
import {
|
|
4
|
+
Field,
|
|
5
|
+
SingleSelect,
|
|
6
|
+
SingleSelectOption,
|
|
7
|
+
MultiSelect,
|
|
8
|
+
MultiSelectOption,
|
|
9
|
+
Loader,
|
|
10
|
+
Box,
|
|
11
|
+
Typography,
|
|
12
|
+
Flex
|
|
13
|
+
} from "@strapi/design-system";
|
|
14
|
+
|
|
15
|
+
// Helper functions (getNestedValue, mapResponseData) ΠΎΡΡΠ°ΡΡΡΡ Π±Π΅Π· ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΠΉ
|
|
16
|
+
const getNestedValue = (obj, path) => {
|
|
17
|
+
if (!path) return obj;
|
|
18
|
+
return path.split('.').reduce((current, key) => {
|
|
19
|
+
return current && current[key] !== undefined ? current[key] : null;
|
|
20
|
+
}, obj);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const mapResponseData = (item, mapping, fallbackLabelKey, fallbackValueKey) => {
|
|
24
|
+
let mapped = { label: '', value: '', item };
|
|
25
|
+
|
|
26
|
+
if (mapping && typeof mapping === 'object') {
|
|
27
|
+
mapped.label =
|
|
28
|
+
getNestedValue(item, mapping.label || mapping.text || mapping.name) ||
|
|
29
|
+
getNestedValue(item, fallbackLabelKey) ||
|
|
30
|
+
item.label || item.name || item.text || 'Unknown';
|
|
31
|
+
|
|
32
|
+
mapped.value =
|
|
33
|
+
getNestedValue(item, mapping.value || mapping.id) ||
|
|
34
|
+
getNestedValue(item, fallbackValueKey) ||
|
|
35
|
+
item.value || item.id || mapped.label;
|
|
36
|
+
|
|
37
|
+
Object.keys(mapping).forEach(key => {
|
|
38
|
+
if (key !== 'label' && key !== 'value') {
|
|
39
|
+
mapped[key] = getNestedValue(item, mapping[key]);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
} else {
|
|
43
|
+
mapped.label =
|
|
44
|
+
getNestedValue(item, fallbackLabelKey) ||
|
|
45
|
+
item.label || item.name || item.text || item.title ||
|
|
46
|
+
item.displayName || item.description || 'Unknown Option';
|
|
47
|
+
|
|
48
|
+
mapped.value =
|
|
49
|
+
getNestedValue(item, fallbackValueKey) ||
|
|
50
|
+
item.value || item.id || item.identifier || item.uuid || mapped.label;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!mapped.label || typeof mapped.label !== 'string') {
|
|
54
|
+
mapped.label = `Option ${mapped.value || 'Unknown'}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!mapped.value) {
|
|
58
|
+
mapped.value = mapped.label || `option_${Date.now()}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return mapped;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const ApiSelectInput = ({
|
|
65
|
+
name,
|
|
66
|
+
value,
|
|
67
|
+
onChange,
|
|
68
|
+
attribute,
|
|
69
|
+
placeholder,
|
|
70
|
+
error,
|
|
71
|
+
required,
|
|
72
|
+
disabled,
|
|
73
|
+
label,
|
|
74
|
+
description,
|
|
75
|
+
intlLabel,
|
|
76
|
+
forwardedRef,
|
|
77
|
+
...props
|
|
78
|
+
}) => {
|
|
79
|
+
const [options, setOptions] = useState([]);
|
|
80
|
+
const [loading, setLoading] = useState(false);
|
|
81
|
+
const [fetchError, setFetchError] = useState(null);
|
|
82
|
+
|
|
83
|
+
const config = attribute?.options || {};
|
|
84
|
+
const configValues = config.options || config;
|
|
85
|
+
|
|
86
|
+
const {
|
|
87
|
+
optionsApi,
|
|
88
|
+
optionLabelKey = 'name',
|
|
89
|
+
optionValueKey = 'id',
|
|
90
|
+
selectMode = 'single',
|
|
91
|
+
authMode = 'public',
|
|
92
|
+
placeholder: configPlaceholder,
|
|
93
|
+
httpMethod = 'GET',
|
|
94
|
+
requestPayload = '',
|
|
95
|
+
customHeaders = '',
|
|
96
|
+
responseDataPath = '',
|
|
97
|
+
responseMappingConfig = '',
|
|
98
|
+
// π₯ ΠΠΎΠ²ΡΠ΅ ΠΏΠΎΠ»Ρ Π΄Π»Ρ ΠΈΠΊΠΎΠ½ΠΎΠΊ
|
|
99
|
+
enableIcons = false,
|
|
100
|
+
iconUrlKey = 'iconUrl',
|
|
101
|
+
} = configValues;
|
|
102
|
+
|
|
103
|
+
const getAuthToken = () => {
|
|
104
|
+
try {
|
|
105
|
+
return (
|
|
106
|
+
localStorage.getItem('jwtToken')?.replaceAll('"', '') ||
|
|
107
|
+
localStorage.getItem('strapi_jwt')?.replaceAll('"', '') ||
|
|
108
|
+
localStorage.getItem('token')?.replaceAll('"', '')
|
|
109
|
+
);
|
|
110
|
+
} catch (e) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (!optionsApi) return;
|
|
117
|
+
|
|
118
|
+
const fetchOptions = async () => {
|
|
119
|
+
setLoading(true);
|
|
120
|
+
setFetchError(null);
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
let url = optionsApi;
|
|
124
|
+
const token = getAuthToken();
|
|
125
|
+
|
|
126
|
+
const fetchOpts = {
|
|
127
|
+
method: httpMethod,
|
|
128
|
+
credentials: authMode === 'proxy' ? 'include' : 'omit',
|
|
129
|
+
headers: {
|
|
130
|
+
'Content-Type': 'application/json',
|
|
131
|
+
...(token && { Authorization: `Bearer ${token}` }),
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
if (customHeaders?.trim()) {
|
|
136
|
+
try {
|
|
137
|
+
Object.assign(fetchOpts.headers, JSON.parse(customHeaders));
|
|
138
|
+
} catch (e) {}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (httpMethod === 'POST' && requestPayload?.trim()) {
|
|
142
|
+
fetchOpts.body = requestPayload;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (authMode === 'proxy') {
|
|
146
|
+
const params = new URLSearchParams({
|
|
147
|
+
api: optionsApi,
|
|
148
|
+
labelKey: optionLabelKey,
|
|
149
|
+
valueKey: optionValueKey,
|
|
150
|
+
method: httpMethod,
|
|
151
|
+
});
|
|
152
|
+
if (requestPayload) params.append('payload', requestPayload);
|
|
153
|
+
if (customHeaders) params.append('headers', customHeaders);
|
|
154
|
+
|
|
155
|
+
url = `${window.strapi.backendURL}/api-select/fetch?${params}`;
|
|
156
|
+
fetchOpts.method = 'GET';
|
|
157
|
+
delete fetchOpts.body;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const response = await fetch(url, fetchOpts);
|
|
161
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
162
|
+
|
|
163
|
+
const data = await response.json();
|
|
164
|
+
|
|
165
|
+
let itemsArray = data;
|
|
166
|
+
if (responseDataPath) {
|
|
167
|
+
itemsArray = getNestedValue(data, responseDataPath);
|
|
168
|
+
} else {
|
|
169
|
+
itemsArray = data.data || data.results || data.items || data.response || data;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!Array.isArray(itemsArray)) {
|
|
173
|
+
setOptions([]);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let customMapping = null;
|
|
178
|
+
if (responseMappingConfig?.trim()) {
|
|
179
|
+
try {
|
|
180
|
+
customMapping = JSON.parse(responseMappingConfig);
|
|
181
|
+
} catch (e) {}
|
|
182
|
+
}
|
|
183
|
+
console.log(itemsArray);
|
|
184
|
+
|
|
185
|
+
const mappedOptions = itemsArray.map(item =>
|
|
186
|
+
mapResponseData(item, customMapping, optionLabelKey, optionValueKey)
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
setOptions(mappedOptions);
|
|
190
|
+
} catch (err) {
|
|
191
|
+
setFetchError(err.message);
|
|
192
|
+
setOptions([]);
|
|
193
|
+
} finally {
|
|
194
|
+
setLoading(false);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
fetchOptions();
|
|
199
|
+
}, [
|
|
200
|
+
optionsApi,
|
|
201
|
+
optionLabelKey,
|
|
202
|
+
optionValueKey,
|
|
203
|
+
authMode,
|
|
204
|
+
httpMethod,
|
|
205
|
+
requestPayload,
|
|
206
|
+
customHeaders,
|
|
207
|
+
responseDataPath,
|
|
208
|
+
responseMappingConfig,
|
|
209
|
+
]);
|
|
210
|
+
|
|
211
|
+
const handleSingleChange = (value) => {
|
|
212
|
+
onChange({ target: { name, value, type: 'json' } });
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const handleMultiChange = (values) => {
|
|
216
|
+
onChange({ target: { name, value: values, type: 'json' } });
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const displayLabel = label || intlLabel?.defaultMessage || name || 'Select Option';
|
|
220
|
+
|
|
221
|
+
const getPlaceholder = () => {
|
|
222
|
+
if (loading) return 'Loading...';
|
|
223
|
+
if (fetchError) return 'Error loading options';
|
|
224
|
+
return placeholder || configPlaceholder || (selectMode === 'single' ? 'Select option...' : 'Select options...');
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// π₯ Π€ΡΠ½ΠΊΡΠΈΡ Π΄Π»Ρ ΡΠ΅Π½Π΄Π΅ΡΠ° ΠΎΠΏΡΠΈΠΈ Ρ ΠΈΠΊΠΎΠ½ΠΊΠΎΠΉ
|
|
228
|
+
const renderOptionContent = (option) => {
|
|
229
|
+
const iconUrl = enableIcons ? option.item[iconUrlKey] : null;
|
|
230
|
+
return (
|
|
231
|
+
<Flex gap={2} alignItems="center">
|
|
232
|
+
{
|
|
233
|
+
iconUrl && (
|
|
234
|
+
<svg
|
|
235
|
+
width={20}
|
|
236
|
+
height={20}
|
|
237
|
+
viewBox="0 0 24 24"
|
|
238
|
+
focusable="false"
|
|
239
|
+
aria-hidden="true"
|
|
240
|
+
>
|
|
241
|
+
<use href={iconUrl} />
|
|
242
|
+
</svg>
|
|
243
|
+
)}
|
|
244
|
+
<span>{option.label}</span>
|
|
245
|
+
</Flex>
|
|
246
|
+
);
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
// Loading state
|
|
250
|
+
if (loading) {
|
|
251
|
+
return (
|
|
252
|
+
<Box padding={2}>
|
|
253
|
+
<Field.Root name={name} error={error} required={required}>
|
|
254
|
+
<Field.Label>{displayLabel}</Field.Label>
|
|
255
|
+
<Flex padding={2} gap={2}>
|
|
256
|
+
<Loader small />
|
|
257
|
+
<Typography variant="pi" textColor="neutral600">
|
|
258
|
+
Loading options...
|
|
259
|
+
</Typography>
|
|
260
|
+
</Flex>
|
|
261
|
+
{description && <Field.Hint>{description}</Field.Hint>}
|
|
262
|
+
<Field.Error />
|
|
263
|
+
</Field.Root>
|
|
264
|
+
</Box>
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Error state
|
|
269
|
+
if (fetchError) {
|
|
270
|
+
return (
|
|
271
|
+
<Box padding={2}>
|
|
272
|
+
<Field.Root name={name} error={error} required={required}>
|
|
273
|
+
<Field.Label>{displayLabel}</Field.Label>
|
|
274
|
+
<Box
|
|
275
|
+
padding={2}
|
|
276
|
+
background="danger100"
|
|
277
|
+
borderColor="danger600"
|
|
278
|
+
borderStyle="solid"
|
|
279
|
+
borderWidth="1px"
|
|
280
|
+
hasRadius
|
|
281
|
+
>
|
|
282
|
+
<Typography variant="pi" textColor="danger600">
|
|
283
|
+
Failed to load options: {fetchError}
|
|
284
|
+
</Typography>
|
|
285
|
+
</Box>
|
|
286
|
+
{description && <Field.Hint>{description}</Field.Hint>}
|
|
287
|
+
<Field.Error />
|
|
288
|
+
</Field.Root>
|
|
289
|
+
</Box>
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// MultiSelect mode
|
|
294
|
+
if (selectMode === 'multiple') {
|
|
295
|
+
return (
|
|
296
|
+
<Box padding={2}>
|
|
297
|
+
<Field.Root name={name} error={error} required={required}>
|
|
298
|
+
<Field.Label>{displayLabel}</Field.Label>
|
|
299
|
+
<MultiSelect
|
|
300
|
+
ref={forwardedRef}
|
|
301
|
+
name={name}
|
|
302
|
+
value={value || []}
|
|
303
|
+
onChange={handleMultiChange}
|
|
304
|
+
disabled={disabled}
|
|
305
|
+
placeholder={getPlaceholder()}
|
|
306
|
+
withTags
|
|
307
|
+
{...props}
|
|
308
|
+
>
|
|
309
|
+
{options.map((option, index) => (
|
|
310
|
+
<MultiSelectOption key={option.value || index} value={option.value || ''}>
|
|
311
|
+
{renderOptionContent(option)}
|
|
312
|
+
</MultiSelectOption>
|
|
313
|
+
))}
|
|
314
|
+
</MultiSelect>
|
|
315
|
+
{description && <Field.Hint>{description}</Field.Hint>}
|
|
316
|
+
<Field.Error />
|
|
317
|
+
</Field.Root>
|
|
318
|
+
</Box>
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Single Select mode
|
|
323
|
+
return (
|
|
324
|
+
<Box padding={2}>
|
|
325
|
+
<Field.Root name={name} error={error} required={required}>
|
|
326
|
+
<Field.Label>{displayLabel}</Field.Label>
|
|
327
|
+
<SingleSelect
|
|
328
|
+
ref={forwardedRef}
|
|
329
|
+
name={name}
|
|
330
|
+
value={value || ''}
|
|
331
|
+
onChange={handleSingleChange}
|
|
332
|
+
disabled={disabled}
|
|
333
|
+
placeholder={getPlaceholder()}
|
|
334
|
+
{...props}
|
|
335
|
+
>
|
|
336
|
+
{options.map((option, index) => (
|
|
337
|
+
<SingleSelectOption key={option.value || index} value={option.value || ''}>
|
|
338
|
+
{renderOptionContent(option)}
|
|
339
|
+
</SingleSelectOption>
|
|
340
|
+
))}
|
|
341
|
+
</SingleSelect>
|
|
342
|
+
{description && <Field.Hint>{description}</Field.Hint>}
|
|
343
|
+
<Field.Error />
|
|
344
|
+
</Field.Root>
|
|
345
|
+
</Box>
|
|
346
|
+
);
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
ApiSelectInput.defaultProps = {
|
|
350
|
+
value: null,
|
|
351
|
+
placeholder: null,
|
|
352
|
+
error: null,
|
|
353
|
+
required: false,
|
|
354
|
+
disabled: false,
|
|
355
|
+
label: null,
|
|
356
|
+
description: null,
|
|
357
|
+
intlLabel: null,
|
|
358
|
+
forwardedRef: null,
|
|
359
|
+
attribute: {},
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
ApiSelectInput.propTypes = {
|
|
363
|
+
name: PropTypes.string.isRequired,
|
|
364
|
+
value: PropTypes.any,
|
|
365
|
+
onChange: PropTypes.func.isRequired,
|
|
366
|
+
attribute: PropTypes.object,
|
|
367
|
+
placeholder: PropTypes.string,
|
|
368
|
+
error: PropTypes.string,
|
|
369
|
+
required: PropTypes.bool,
|
|
370
|
+
disabled: PropTypes.bool,
|
|
371
|
+
label: PropTypes.string,
|
|
372
|
+
description: PropTypes.string,
|
|
373
|
+
intlLabel: PropTypes.shape({
|
|
374
|
+
id: PropTypes.string,
|
|
375
|
+
defaultMessage: PropTypes.string,
|
|
376
|
+
}),
|
|
377
|
+
forwardedRef: PropTypes.any,
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
export default ApiSelectInput;
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@devcustrom/strapi-plugin-api-select",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "A powerful Strapi v5 plugin that provides API-driven select fields with support for GET/POST requests, custom headers, flexible response mapping, and multilingual content.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"main": "strapi-server.js",
|
|
7
|
+
"author": {
|
|
8
|
+
"name": "devcustrom",
|
|
9
|
+
"url": "https://github.com/devcustrom"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "https://github.com/devcustrom/strapi-plugin-api-select.git"
|
|
14
|
+
},
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/devcustrom/strapi-plugin-api-select/issues"
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://github.com/devcustrom/strapi-plugin-api-select#readme",
|
|
19
|
+
"strapi": {
|
|
20
|
+
"name": "api-select",
|
|
21
|
+
"displayName": "API Select",
|
|
22
|
+
"description": "Dynamic select fields powered by external APIs",
|
|
23
|
+
"kind": "plugin",
|
|
24
|
+
"type": "custom-field"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@strapi/strapi": "^5.0.0"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18.0.0",
|
|
31
|
+
"npm": ">=8.0.0"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"admin",
|
|
35
|
+
"server",
|
|
36
|
+
"strapi-admin.js",
|
|
37
|
+
"strapi-server.js",
|
|
38
|
+
"README.md",
|
|
39
|
+
"LICENSE",
|
|
40
|
+
"CHANGELOG.md"
|
|
41
|
+
],
|
|
42
|
+
"keywords": [
|
|
43
|
+
"strapi",
|
|
44
|
+
"strapi-plugin",
|
|
45
|
+
"custom-field",
|
|
46
|
+
"select",
|
|
47
|
+
"dropdown",
|
|
48
|
+
"api",
|
|
49
|
+
"dynamic",
|
|
50
|
+
"remote-data",
|
|
51
|
+
"external-api",
|
|
52
|
+
"v5",
|
|
53
|
+
"cms",
|
|
54
|
+
"headless"
|
|
55
|
+
],
|
|
56
|
+
"scripts": {
|
|
57
|
+
"test": "echo \"No tests yet\" && exit 0"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
const { URL } = require("url");
|
|
2
|
+
|
|
3
|
+
module.exports = {
|
|
4
|
+
async fetch(ctx) {
|
|
5
|
+
try {
|
|
6
|
+
const {
|
|
7
|
+
api,
|
|
8
|
+
labelKey = "name",
|
|
9
|
+
valueKey = "id",
|
|
10
|
+
method = "GET",
|
|
11
|
+
payload,
|
|
12
|
+
headers: customHeadersStr,
|
|
13
|
+
} = ctx.query;
|
|
14
|
+
|
|
15
|
+
if (!api) {
|
|
16
|
+
return ctx.badRequest("API URL is required");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Basic SSRF protection
|
|
20
|
+
const url = new URL(api);
|
|
21
|
+
if (!["http:", "https:"].includes(url.protocol)) {
|
|
22
|
+
return ctx.badRequest("Only HTTP/HTTPS URLs allowed");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Prepare fetch options
|
|
26
|
+
const fetchOptions = {
|
|
27
|
+
method: method.toUpperCase(),
|
|
28
|
+
headers: {
|
|
29
|
+
"Content-Type": "application/json",
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Add environment token if available
|
|
34
|
+
if (process.env.CUSTOM_STRAPI_SELECT_TOKEN) {
|
|
35
|
+
fetchOptions.headers.Authorization = `Bearer ${process.env.CUSTOM_STRAPI_SELECT_TOKEN}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Parse and add custom headers
|
|
39
|
+
if (customHeadersStr) {
|
|
40
|
+
try {
|
|
41
|
+
const customHeaders = JSON.parse(customHeadersStr);
|
|
42
|
+
fetchOptions.headers = { ...fetchOptions.headers, ...customHeaders };
|
|
43
|
+
strapi.log.info("Added custom headers to proxy request");
|
|
44
|
+
} catch (error) {
|
|
45
|
+
strapi.log.warn("Invalid custom headers JSON:", error.message);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Add request body for POST requests
|
|
50
|
+
if (method.toUpperCase() === "POST" && payload) {
|
|
51
|
+
try {
|
|
52
|
+
// Validate that payload is valid JSON
|
|
53
|
+
JSON.parse(payload);
|
|
54
|
+
fetchOptions.body = payload;
|
|
55
|
+
strapi.log.info("Added POST payload to proxy request");
|
|
56
|
+
} catch (error) {
|
|
57
|
+
return ctx.badRequest("Invalid JSON payload");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
strapi.log.info(`Proxying ${method.toUpperCase()} request to: ${api}`);
|
|
62
|
+
|
|
63
|
+
// Fetch data
|
|
64
|
+
const response = await fetch(api, fetchOptions);
|
|
65
|
+
|
|
66
|
+
if (!response.ok) {
|
|
67
|
+
throw new Error(`API responded with ${response.status}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let data = await response.json();
|
|
71
|
+
|
|
72
|
+
// Handle both array and {data: array} formats
|
|
73
|
+
if (data.data && Array.isArray(data.data)) {
|
|
74
|
+
data = data.data;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!Array.isArray(data)) {
|
|
78
|
+
return ctx.badRequest("API must return an array or {data: array}");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
ctx.body = data;
|
|
82
|
+
} catch (error) {
|
|
83
|
+
strapi.log.error("Proxy fetch error:", error.message);
|
|
84
|
+
ctx.internalServerError("Failed to fetch options");
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
};
|
package/server/index.js
ADDED
package/strapi-admin.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./admin/src";
|
package/strapi-server.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = require("./server");
|