├── README.md
├── index.ts
└── package.json
/README.md:
--------------------------------------------------------------------------------
1 | # Simple REST Data Provider for React Admin - Strapi
2 |
3 | [React Admin](https://marmelab.com/react-admin/) data provider for Strapi.js.
4 |
5 | # Strapi V4 Update
6 |
7 | ### RA-STRAPI-REST works with Strapi V4. 🚀
8 |
9 | If you want to use the version compatible with Strapi V3 check [this PR](https://github.com/nazirov91/ra-strapi-rest/blob/86c6964a352aeb8aac86ba1b6b95b1801dc3e782/index.js).
10 |
11 | Also, it is converted to TypeScript.
12 |
13 | ### Check out this demo for reference => [Demo](https://github.com/nazirov91/ra-strapi-rest-demo)
14 |
15 | - [x] Works with Single Types
16 | - [x] Works with Collection types
17 | - [x] Works with Components
18 | - [x] Handles single and multiple Media files
19 | - [x] Handles basic filtering
20 | - [x] Handles sorting and pagination
21 | - [x] Works with reference fields/inputs under community version
22 | - [x] Tested with Sqlite and Postgres
23 |
24 | Please submit a PR with an example if you find any bugs. Thanks!
25 |
26 | # Usage
27 |
28 | Save the **index.ts** file as `ra-strapi-rest.ts` and import it in your react-admin project. No need to npm install another dependency :)
29 |
30 | ```tsx
31 | // App.tsx
32 |
33 | import raStrapiRest from "./ra-strapi-rest";
34 | ```
35 |
36 | If you prefer to add this to node modules, go ahead and run the following command
37 |
38 | ```
39 | npm install ra-strapi-rest
40 | ```
41 |
42 | or
43 |
44 | ```
45 | yarn add ra-strapi-rest
46 | ```
47 |
48 | Then import it in your `App.tsx` as usual
49 |
50 | ```tsx
51 | import raStrapiRest from "ra-strapi-rest";
52 | ```
53 |
54 | # Example
55 |
56 | ```tsx
57 | import { fetchUtils, Admin, Resource } from "react-admin";
58 | import { ArticleList } from "./pages/articles/articleList";
59 | import raStrapiRest from "./ra-strapi-rest";
60 |
61 | const strapiApiUrl = "http://localhost:1337/api";
62 |
63 | const httpClient = (url: string, options: any = {}) => {
64 | options.headers = options.headers || new Headers({ Accept: "application/json" });
65 | options.headers.set("Authorization", `Bearer ${import.meta.env.VITE_STRAPI_API_TOKEN}`);
66 | return fetchUtils.fetchJson(url, options);
67 | };
68 |
69 | export const dataProvider = raStrapiRest(strapiApiUrl, httpClient);
70 |
71 | const App = () => (
72 |
73 |
74 |
75 | );
76 |
77 | export default App;
78 | ```
79 |
80 | ArticleList Component:
81 |
82 | ```tsx
83 | import { List, Datagrid, TextField } from "react-admin";
84 |
85 | export const ArticleList = () => (
86 |
87 |
88 |
89 |
90 |
91 |
92 | );
93 | ```
94 |
95 | ### Check out this demo for detailed reference => [Demo](https://github.com/nazirov91/ra-strapi-rest-demo)
96 |
--------------------------------------------------------------------------------
/index.ts:
--------------------------------------------------------------------------------
1 | import {
2 | fetchUtils,
3 | DataProvider,
4 | GET_LIST,
5 | GET_ONE,
6 | GET_MANY_REFERENCE,
7 | CREATE,
8 | UPDATE,
9 | DELETE,
10 | } from "react-admin";
11 | export const SingleType = "SingleType";
12 |
13 | const raStrapiRest = (apiUrl: string, httpClient = fetchUtils.fetchJson): DataProvider => {
14 | /**
15 | * Adjusts the query parameters for Strapi, including sorting, filtering, and pagination.
16 | * @param params - The input parameters containing pagination, sorting, filtering, target, and ID data.
17 | * @returns A query string for Strapi with the adjusted parameters.
18 | */
19 | const adjustQueryForStrapi = (params: any): string => {
20 | /* Sample parameters object:
21 | params = {
22 | pagination: { page: {int}, perPage: {int} },
23 | sort: { field: {string}, order: {string} },
24 | filter: {Object},
25 | target: {string}, (REFERENCE ONLY)
26 | id: {mixed} (REFERENCE ONLY)
27 | }
28 | */
29 |
30 | // Handle SORTING
31 | const s = params.sort;
32 | const sort =
33 | s.field === "" ? "sort=updated_at:desc" : "sort=" + s.field + ":" + s.order.toLowerCase();
34 |
35 | // Handle FILTER
36 | const f = params.filter;
37 | let filter = "";
38 | const keys = Object.keys(f);
39 | for (let i = 0; i < keys.length; i++) {
40 | if (keys[i] === "q" && f[keys[i]] !== "") {
41 | filter += "_q=" + f[keys[i]] + (keys[i + 1] ? "&" : "");
42 | } else {
43 | filter += "filters[" + keys[i] + "]_eq=" + f[keys[i]] + (keys[i + 1] ? "&" : "");
44 | }
45 | }
46 | if (params.id && params.target && params.target.indexOf("_id") !== -1) {
47 | const target = params.target.substring(0, params.target.length - 3);
48 | filter += "&filters[" + target + "]_eq=" + params.id;
49 | }
50 |
51 | // Handle PAGINATION
52 | const { page, perPage } = params.pagination;
53 | const start = (page - 1) * perPage;
54 | const pagination = "pagination[start]=" + start + "&pagination[limit]=" + perPage;
55 |
56 | return sort + "&" + pagination + "&" + filter;
57 | };
58 |
59 | const getUploadFieldNames = (data: any): string[] => {
60 | if (!data || typeof data !== "object") return [];
61 | const hasRawFile = (value: any): boolean => {
62 | return (
63 | value &&
64 | typeof value === "object" &&
65 | ("rawFile" in value ||
66 | (Array.isArray(value) && value.some(hasRawFile)) ||
67 | Object.values(value).some(hasRawFile))
68 | );
69 | };
70 |
71 | return Object.keys(data).filter((key: any) => hasRawFile(data[key]));
72 | };
73 |
74 | /**
75 | * Handles file uploads and data updates for a given resource.
76 | * @param type - The operation type, either UPDATE or a different value for creating new resources.
77 | * @param resource - The target resource for the file upload or data update.
78 | * @param params - The parameters containing the data to be uploaded or updated.
79 | * @returns The processed response from the server, converted to the appropriate format.
80 | */
81 | const handleFileUpload = async (type: any, resource: any, params: any) => {
82 | const id = type === UPDATE ? `/${params.id}` : "";
83 | const url = `${apiUrl}/${resource}${params.id == SingleType ? "" : id}`;
84 | const requestMethod = type === UPDATE ? "PUT" : "POST";
85 | const formData = new FormData();
86 | const uploadFieldNames = getUploadFieldNames(params.data);
87 |
88 | const { created_at, updated_at, createdAt, updatedAt, ...data } = params.data;
89 | uploadFieldNames.forEach((fieldName) => {
90 | const fieldData = Array.isArray(params.data[fieldName])
91 | ? params.data[fieldName]
92 | : [params.data[fieldName]];
93 | data[fieldName] = fieldData.reduce((acc: any, item: any) => {
94 | item.rawFile instanceof File
95 | ? formData.append(`files.${fieldName}`, item.rawFile)
96 | : acc.push(item.id || item._id);
97 | return acc;
98 | }, []);
99 | });
100 |
101 | formData.append("data", JSON.stringify(data));
102 |
103 | const response = await processRequest(url, { method: requestMethod, body: formData });
104 | return convertHTTPResponse(response, type, params);
105 | };
106 |
107 | /**
108 | * Formats the response from Strapi to react-admin compatible data
109 | * @param input - The input data to be formatted.
110 | * @returns The formatted input, either as a single object or an array of objects.
111 | */
112 | const formatResponseForRa = (input: any): any => {
113 | if (!input || input.length === 0) return input;
114 | const processItem = (item: any) => {
115 | const json = { id: item.id, ...item.attributes };
116 |
117 | for (const key in json) {
118 | const { data } = json[key] || {};
119 | const isArray = Array.isArray(data);
120 | const isMime = data && (data[0]?.attributes?.mime || data.attributes?.mime);
121 | if (!data || (isArray && data[0]?.length === 0)) continue;
122 | const processUrl = (url: string) => `${apiUrl.replace(/\/api$/, "")}${url}`;
123 |
124 | if (isArray && isMime) {
125 | json[key] = data.map(({ id, attributes }) => ({
126 | id,
127 | ...attributes,
128 | url: processUrl(attributes.url),
129 | }));
130 | continue;
131 | }
132 |
133 | if (!isArray && isMime) {
134 | json[key] = { id: data.id, ...data.attributes, url: processUrl(data.attributes.url) };
135 | } else {
136 | json[key] = isArray ? data.map(({ id }) => id) : data.id.toString();
137 | }
138 | }
139 | return json;
140 | };
141 |
142 | return Array.isArray(input) ? input.map(processItem) : [processItem(input)][0];
143 | };
144 |
145 | const convertHTTPResponse = (response: any, type: string, params: any): any => {
146 | const { json } = response;
147 | const raData = formatResponseForRa(json.data);
148 | switch (type) {
149 | case GET_ONE:
150 | return { data: raData };
151 | case GET_LIST:
152 | case GET_MANY_REFERENCE:
153 | return {
154 | data: raData,
155 | total: json.meta.pagination.total,
156 | };
157 | case CREATE:
158 | return { data: { ...params.data, id: raData.id } };
159 | case DELETE:
160 | return { data: { id: null } };
161 | default:
162 | return { data: raData };
163 | }
164 | };
165 |
166 | const processRequest = async (url: string, options = {}) => {
167 | const separator = url.includes("?") ? "&" : "?";
168 | return httpClient(`${url}${separator}populate=*`, options);
169 | };
170 |
171 | return {
172 | getList: async (resource: string, params: any) => {
173 | const url = `${apiUrl}/${resource}?${adjustQueryForStrapi(params)}`;
174 | const res = await processRequest(url, {});
175 | return convertHTTPResponse(res, GET_LIST, params);
176 | },
177 |
178 | getOne: async (resource: string, params: any) => {
179 | const isSingleType = params.id === SingleType;
180 | const url = `${apiUrl}/${resource}${isSingleType ? "" : "/" + params.id}`;
181 | const res = await processRequest(url, {});
182 | return convertHTTPResponse(res, GET_ONE, params);
183 | },
184 |
185 | getMany: async (resource: string, params: any) => {
186 | if (params.ids.length === 0) return { data: [] };
187 | const ids = params.ids.filter(
188 | (i: any) => !(typeof i === "object" && i.hasOwnProperty("data") && i.data === null)
189 | );
190 |
191 | const responses = await Promise.all(
192 | ids.map((i: any) => {
193 | return processRequest(`${apiUrl}/${resource}/${i.id || i._id || i}`, {
194 | method: "GET",
195 | });
196 | })
197 | );
198 | return {
199 | data: responses.map((response) => formatResponseForRa(response.json.data)),
200 | };
201 | },
202 |
203 | getManyReference: async (resource: string, params: any) => {
204 | const url = `${apiUrl}/${resource}?${adjustQueryForStrapi(params)}`;
205 | const res = await processRequest(url, {});
206 | return convertHTTPResponse(res, GET_MANY_REFERENCE, params);
207 | },
208 |
209 | update: async (resource: string, params: any) => {
210 | if (getUploadFieldNames(params.data).length > 0)
211 | return await handleFileUpload(UPDATE, resource, params);
212 |
213 | const isSingleType = params.id === SingleType;
214 | const url = `${apiUrl}/${resource}${isSingleType ? "" : "/" + params.id}`;
215 | const options: any = {};
216 | options.method = "PUT";
217 | // Omit created_at/updated_at(RDS) and createdAt/updatedAt(Mongo) in request body
218 | const { created_at, updated_at, createdAt, updatedAt, ...data } = params.data;
219 | options.body = JSON.stringify({ data });
220 |
221 | const res = await processRequest(url, options);
222 | return convertHTTPResponse(res, UPDATE, params);
223 | },
224 |
225 | updateMany: async (resource: string, params: any) => {
226 | const responses = await Promise.all(
227 | params.ids.map((id: any) => {
228 | // Omit created_at/updated_at(RDS) and createdAt/updatedAt(Mongo) in request body
229 | const { created_at, updated_at, createdAt, updatedAt, ...data } = params.data;
230 | return processRequest(`${apiUrl}/${resource}/${id}`, {
231 | method: "PUT",
232 | body: JSON.stringify(data),
233 | });
234 | })
235 | );
236 | return {
237 | data: responses.map((response) => formatResponseForRa(response.json.data)),
238 | };
239 | },
240 |
241 | create: async (resource: string, params: any) => {
242 | if (getUploadFieldNames(params.data).length > 0)
243 | return await handleFileUpload(CREATE, resource, params);
244 |
245 | const url = `${apiUrl}/${resource}`;
246 | const res = await processRequest(url, {
247 | method: "POST",
248 | body: JSON.stringify(params.data),
249 | });
250 | return convertHTTPResponse(res, CREATE, { data: params.data });
251 | },
252 |
253 | delete: async (resource: string, { id }: any) => {
254 | const url = `${apiUrl}/${resource}${id === SingleType ? "" : `/${id}`}`;
255 | const res = await processRequest(url, { method: "DELETE" });
256 | return convertHTTPResponse(res, DELETE, { id });
257 | },
258 |
259 | deleteMany: async (resource: string, { ids }: any) => {
260 | const data = await Promise.all(
261 | ids.map(async (id: any) => {
262 | const response = await processRequest(`${apiUrl}/${resource}/${id}`, {
263 | method: "DELETE",
264 | });
265 | return formatResponseForRa(response?.json.data);
266 | })
267 | );
268 | return { data };
269 | },
270 | };
271 | };
272 |
273 | export default raStrapiRest;
274 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ra-strapi-rest",
3 | "version": "0.2.0",
4 | "description": "A simple REST data provider to connect React-Admin and Strapi V4",
5 | "main": "index.ts",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1"
8 | },
9 | "repository": {
10 | "type": "git",
11 | "url": "git+https://github.com/nazirov91/ra-strapi-rest.git"
12 | },
13 | "keywords": [
14 | "strapi",
15 | "react-admin",
16 | "react",
17 | "admin-on-rest",
18 | "rest"
19 | ],
20 | "author": "Sardor (sardor.io)",
21 | "license": "MIT",
22 | "bugs": {
23 | "url": "https://github.com/nazirov91/ra-strapi-rest/issues"
24 | },
25 | "homepage": "https://github.com/nazirov91/ra-strapi-rest#readme"
26 | }
27 |
--------------------------------------------------------------------------------