├── 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 | --------------------------------------------------------------------------------