UNPKG

refine-apito

Version:

A data provider for Refine that connects to Apito - a headless CMS and backend builder.

1,089 lines (992 loc) 34.8 kB
import { BaseRecord, CreateManyParams, CreateManyResponse, CreateParams, CreateResponse, CustomParams, GetListParams, GetListResponse, GetOneParams, GetOneResponse, HttpError, } from '@refinedev/core'; import { Client, CombinedError, cacheExchange, fetchExchange, gql } from '@urql/core'; import { apitoConnectionFilterConditionType, apitoGraphQLComposedTypeName, apitoListCountKeyConditionType, apitoListCountWhereInputType, apitoListGraphQLTypeName, apitoListKeyConditionType, apitoMultipleResourceName, apitoSingularGraphQLTypeName, apitoModelName, apitoSortInputType, apitoWhereInputType, apitoWhereRelationFilterConditionType, buildApitoCreateMutation, formatApitoConnectionSubselections, } from './apitoGraphqlNames'; import { ApitoGraphQLError, CustomResponse, ExtendedDataProvider, ResponseType, SingleResponseType, } from './types'; /** Property names that must not come from user-controlled filter input (prototype pollution). */ const UNSAFE_DYNAMIC_KEYS = new Set(['__proto__', 'constructor', 'prototype']); function isSafeDynamicKey(key: unknown): key is string { return typeof key === 'string' && key.length > 0 && !UNSAFE_DYNAMIC_KEYS.has(key); } /* Apito Typical Graphql Error Response: { "data": { "deleteTestLabel": null }, "errors": [ { "message": "there are 1 relations that are using this document, please delete them first", "locations": [ { "line": 2, "column": 3 } ], "path": [ "deleteTestLabel" ] } ] } */ /* Apito Typical Graphql Success Response: { "data": { "testLabelList": [ { "data": { "description": { "text": null }, "measure_unit": "mmol/l", "name": "Corres. Urine Sugar", "reference_range": "<7.8 mmol/l" }, "id": "1ac785e3-a190-44a5-bc36-d858df8a3868", "meta": { "created_at": "2025-03-10T08:10:55Z", "status": true, "updated_at": "2025-03-10T08:10:55Z" } }, { "data": { "description": { "text": null }, "measure_unit": "mmol/l", "name": "P Glucose (F)", "reference_range": "3.6-5.6 mmol/l" }, "id": "0c7e3a18-765c-4fed-a091-768578804fdc", "meta": { "created_at": "2025-03-10T08:10:05Z", "status": true, "updated_at": "2025-03-10T08:10:05Z" } }, { "data": { "description": { "text": null }, "measure_unit": "mmol/L", "name": "T4", "reference_range": "3.6-5.6 mmol/L" }, "id": "13123014-8bb7-4850-9699-8eb4f0607305", "meta": { "created_at": "2025-02-17T13:32:56Z", "status": true, "updated_at": "2025-02-17T13:32:56Z" } }, { "data": { "description": { "text": null }, "measure_unit": "mg/dl", "name": "S. Creatinine", "reference_range": "0.6-1.2 mg/dl" }, "id": "c9c9c9c9-c9c9-c9c9-c9c9-c9c9c9c9c9c9", "meta": { "created_at": "2025-02-17T13:32:56Z", "status": true, "updated_at": "2025-02-17T13:32:56Z" } } ], "testLabelListCount": { "total": 4 } } } */ /** * Handles GraphQL errors from Apito responses * @param error The error object from urql client * @param onTokenExpired Optional callback for handling 403 token expiration * @returns An HttpError object with appropriate status code and message */ const handleGraphQLError = ( error: CombinedError | undefined, onTokenExpired?: () => void ): HttpError => { if (!error) { return { message: 'Unknown error occurred', statusCode: 500, }; } // Handle network errors if (error.networkError) { // Check for 403 status in network error const statusCode = (error.networkError as any).statusCode || (error.networkError as any).status; if (statusCode === 403 || statusCode === 401) { console.log('Token expired (403/401), triggering logout...'); onTokenExpired?.(); return { message: 'Token expired. Please login again.', statusCode: 403, }; } return { message: `Network error: ${error.networkError.message}`, statusCode: statusCode || 503, // Service Unavailable }; } // Handle GraphQL errors if (error.graphQLErrors && error.graphQLErrors.length > 0) { // Check for authentication/authorization errors in GraphQL errors const hasAuthError = error.graphQLErrors.some( (err) => err.message.toLowerCase().includes('unauthorized') || err.message.toLowerCase().includes('forbidden') || err.message.toLowerCase().includes('token') || err.message.toLowerCase().includes('authentication') || err.message.toLowerCase().includes('authorization') ); if (hasAuthError) { console.log( 'Authentication error detected in GraphQL, triggering logout...' ); onTokenExpired?.(); return { message: 'Authentication failed. Please login again.', statusCode: 403, }; } const errorMessages = error.graphQLErrors .map((err) => err.message) .join(', '); return { message: errorMessages, statusCode: 400, // Bad Request for GraphQL validation errors }; } // Fallback error return { message: error.message || 'An error occurred during the GraphQL operation', statusCode: 400, }; }; const apitoDataProvider = ( apiUrl: string, token: string, onTokenExpired?: () => void ): ExtendedDataProvider => { const client = new Client({ url: apiUrl, exchanges: [cacheExchange, fetchExchange], fetchOptions: () => ({ method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', }, }), preferGetMethod: false, }); return { getApiUrl: () => apiUrl, getApiClient: () => { return new Client({ url: apiUrl, exchanges: [cacheExchange, fetchExchange], fetchOptions: () => ({ method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', }, }), preferGetMethod: false, }); }, getToken: () => token, async getList<TData extends BaseRecord = BaseRecord>( params: GetListParams ): Promise<GetListResponse<TData>> { try { const { resource, filters, sorters, pagination, meta } = params; const connectionFields = meta?.connectionFields || {}; const aliasFields = meta?.aliasFields || {}; const reverseLookup = meta?.reverseLookup || {}; let data: TData[] = []; let total = 0; let query = null; let variables = null; if (meta?.gqlQuery) { query = meta.gqlQuery; variables = meta.variables; const queryKey = meta.queryKey || resource; const response = await client .query<ResponseType>(query as any, variables) .toPromise(); if (response.error) { return Promise.reject( handleGraphQLError(response.error, onTokenExpired) ); } const queryResponse = response?.data?.[queryKey]; const responseData = ( Array.isArray(queryResponse) ? (queryResponse as unknown as TData[]) : [] ) as TData[]; const responseTotal = responseData.length ?? 0; return { data: responseData, total: responseTotal, }; } else { const fields = meta?.fields || ['id']; // Fallback to 'id' if fields are not provided const listPascal = apitoListGraphQLTypeName(resource); const pluralResource = listPascal; // Helper function to process filters recursively const processFilter = (filter: any): any => { const { field, operator, value } = filter; // Handle special case where operator is "eq" and value is an array if (operator === 'eq' && Array.isArray(value)) { // Create a nested object structure for this case (null prototype + safe keys) const nestedCondition = Object.create(null) as Record< string, Record<string, unknown> >; value.forEach((condition) => { const { field: subField, operator: subOperator, value: subValue, } = condition; if ( subField && subOperator && subValue !== undefined && isSafeDynamicKey(subField) && isSafeDynamicKey(subOperator) ) { if (!nestedCondition[subField]) { nestedCondition[subField] = Object.create(null) as Record< string, unknown >; } nestedCondition[subField][subOperator] = subValue; } }); if (!isSafeDynamicKey(field)) { return {}; } return { [field]: nestedCondition }; } // Handle OR operation if (operator === 'or' && Array.isArray(value)) { const orConditions = Object.create(null) as Record<string, unknown>; value.forEach((condition) => { const { field, operator, value } = condition; if (field && operator && value !== undefined) { // Adjust `data.name` to `name` const adjustedField = field.startsWith('data.') ? field.replace('data.', '') : field; if (isSafeDynamicKey(adjustedField) && isSafeDynamicKey(operator)) { orConditions[adjustedField] = { [operator]: value }; } } }); return { OR: orConditions }; } // Handle AND operation if (operator === 'and' && Array.isArray(value)) { const andConditions = Object.create(null) as Record<string, unknown>; value.forEach((condition) => { const { field, operator, value } = condition; if (field && operator && value !== undefined) { // Adjust `data.name` to `name` const adjustedField = field.startsWith('data.') ? field.replace('data.', '') : field; if (isSafeDynamicKey(adjustedField) && isSafeDynamicKey(operator)) { andConditions[adjustedField] = { [operator]: value }; } } }); return { AND: andConditions }; } // Handle regular field filters if (field === '_key') { const keyOp = operator || 'eq'; if (!isSafeDynamicKey(keyOp)) { return {}; } return { _key: { [keyOp]: value } }; } if (field && field.includes('relation.')) { const relationPath = field.replace('relation.', '').split('.'); if (!relationPath.length || !relationPath.every(isSafeDynamicKey)) { return {}; } const relationCondition = Object.create(null) as Record<string, any>; // Build nested object structure let current: Record<string, any> = relationCondition; for (let i = 0; i < relationPath.length - 1; i++) { const part = relationPath[i]; if (!current[part]) { current[part] = Object.create(null) as Record<string, any>; } current = current[part]; } const lastPart = relationPath[relationPath.length - 1]; if ( operator && value !== undefined && isSafeDynamicKey(lastPart) && isSafeDynamicKey(operator) ) { current[lastPart] = { [operator]: value }; } return { relation: relationCondition }; } if (operator && value !== undefined && typeof field === 'string') { // Adjust `data.name` to `name` const adjustedField = field.startsWith('data.') ? field.replace('data.', '') : field; if (isSafeDynamicKey(adjustedField) && isSafeDynamicKey(operator)) { return { [adjustedField]: { [operator]: value } }; } } return {}; }; // Process filters let _key = null; let relationWhere: Record<string, any> | null = null; let where: Record<string, any> = {}; if (filters && filters.length > 0) { filters.forEach((filter) => { const processed = processFilter(filter); // Extract _key if present if (processed._key) { _key = processed._key; } // Extract relation if present else if (processed.relation) { if (!relationWhere) { relationWhere = {}; } Object.assign(relationWhere, processed.relation); } // Handle OR/AND conditions else if (processed.OR) { where.OR = processed.OR; } else if (processed.AND) { where.AND = processed.AND; } // Handle regular conditions else { Object.assign(where, processed); } }); } const hasKey = _key !== null; const hasRelationWhere = relationWhere !== null; const queryVariables = [ hasKey ? `$_key: ${apitoListKeyConditionType(resource)}` : null, `$connection: ${apitoConnectionFilterConditionType(resource)}`, `$where: ${apitoWhereInputType(resource)}`, hasRelationWhere ? `$relationWhere: ${apitoWhereRelationFilterConditionType(resource)}` : null, hasKey ? `$_keyCount: ${apitoListCountKeyConditionType(resource)}` : null, `$whereCount: ${apitoListCountWhereInputType(resource)}`, hasRelationWhere ? `$relationWhereCount: ${apitoWhereRelationFilterConditionType(resource)}` : null, `$sort: ${apitoSortInputType(resource)}`, `$page: Int`, `$limit: Int`, `$local: LOCAL_TYPE_ENUM`, ] .filter(Boolean) .join('\n'); const queryArguments = [ hasKey ? '_key: $_key' : null, 'connection: $connection', 'where: $where', hasRelationWhere ? 'relation: $relationWhere' : null, 'sort: $sort', 'page: $page', 'limit: $limit', 'local: $local', ] .filter(Boolean) .join(', '); const countArguments = [ hasKey ? '_key: $_keyCount' : null, 'connection: $connection', 'where: $whereCount', hasRelationWhere ? 'relation: $relationWhereCount' : null, 'page: $page', 'limit: $limit', ] .filter(Boolean) .join(', '); query = gql` query Get${pluralResource}( ${queryVariables} ) { ${apitoMultipleResourceName(resource)}(${queryArguments}) { id data { ${fields.join('\n')} } ${formatApitoConnectionSubselections(connectionFields, aliasFields)} meta { created_at status updated_at } } ${apitoMultipleResourceName(resource)}Count(${countArguments}) { total } } `; variables = { ...(hasKey && { _key: _key }), connection: reverseLookup || {}, where: where || {}, ...(hasRelationWhere && { relationWhere: relationWhere }), whereCount: where || {}, ...(hasKey && { _keyCount: _key }), ...(hasRelationWhere && { relationWhereCount: relationWhere }), sort: sorters?.reduce((acc: Record<string, any>, sorter: any) => { const { field, order } = sorter; if (field && order) { acc[field] = order.toUpperCase(); // Convert to ASC/DESC } return acc; }, {}), page: pagination?.currentPage || 1, limit: pagination?.pageSize || 10, }; const response = await client .query<ResponseType>(query as any, variables) .toPromise(); if (response.error) { return Promise.reject( handleGraphQLError(response.error, onTokenExpired) ); } const listRoot = apitoMultipleResourceName(resource); data = (response?.data?.[listRoot] ?? []) as unknown as TData[]; total = 'total' in (response?.data?.[`${listRoot}Count`] || {}) ? (response?.data?.[`${listRoot}Count`] as SingleResponseType) .total : 0; } return { data: data, total: total, }; } catch (error) { if ((error as any).statusCode !== undefined) { return Promise.reject(error); } const httpError: HttpError = { message: (error as Error)?.message || 'Failed to fetch list data', statusCode: 500, }; return Promise.reject(httpError); } }, async getOne<TData extends BaseRecord = BaseRecord>( params: GetOneParams ): Promise<GetOneResponse<TData>> { try { const { resource, id, meta } = params; const fields = meta?.fields || ['id']; // Fallback to 'id' if fields are not provided const connectionFields = meta?.connectionFields || {}; const aliasFields = meta?.aliasFields || {}; const singularField = apitoModelName(resource); const singularPascal = apitoSingularGraphQLTypeName(resource); const query = gql` query Get${singularPascal}($id: String!) { ${singularField}(_id: $id) { id data { ${fields.join('\n')} } ${formatApitoConnectionSubselections(connectionFields, aliasFields)} meta { created_at status updated_at } } } `; const response = await client .query<ResponseType>(query, { id }) .toPromise(); if (response.error) { return Promise.reject( handleGraphQLError(response.error, onTokenExpired) ); } const data = (response?.data?.[singularField] ?? {}) as TData; return { data: data, }; } catch (error) { if ((error as any).statusCode !== undefined) { return Promise.reject(error); } const httpError: HttpError = { message: (error as Error)?.message || `Failed to fetch ${params.resource} with id ${params.id}`, statusCode: 500, }; return Promise.reject(httpError); } }, async create<TData extends BaseRecord = BaseRecord, TVariables = any>( params: CreateParams<TVariables> ): Promise<CreateResponse<TData>> { try { const { resource, variables, meta } = params; let query = null; let _variables = null; if (meta?.gqlMutation) { query = meta.gqlMutation; if (variables) { _variables = variables; } else { _variables = meta.variables; } const response = await client .mutation<ResponseType>(query as any, _variables) .toPromise(); if (response.error) { return Promise.reject(handleGraphQLError(response.error)); } return { data: ( response?.data?.[ `create${apitoSingularGraphQLTypeName(resource)}` ] as SingleResponseType )?.data ?? {}, }; } else { try { const { resource, variables, meta } = params; const fields = (meta?.fields || ['id']) as string[]; // Fallback to 'id' if fields are not provided const name = apitoSingularGraphQLTypeName(resource); const query = gql(buildApitoCreateMutation(resource, fields)); const variableData = variables as Record<string, any>; const response = await client .mutation<ResponseType>(query, { payload: variableData.data, connect: variableData.connect, }) .toPromise(); if (response.error) { return Promise.reject( handleGraphQLError(response.error, onTokenExpired) ); } const data = (response?.data?.[`create${name}`] ?? {}) as TData; return { data: data }; } catch (error) { if ((error as any).statusCode !== undefined) { return Promise.reject(error); } const httpError: HttpError = { message: (error as Error)?.message || `Failed to create ${params.resource}`, statusCode: 500, }; return Promise.reject(httpError); } } } catch (error) { if ((error as any).statusCode !== undefined) { return Promise.reject(error); } const httpError: HttpError = { message: (error as Error)?.message || `Failed to create ${params.resource}`, statusCode: 500, }; return Promise.reject(httpError); } }, async createMany<TData extends BaseRecord = BaseRecord, TVariables = any>( params: CreateManyParams<TVariables> ): Promise<CreateManyResponse<TData>> { try { const { resource, variables, meta } = params; const fields = meta?.fields || ['id']; // Fallback to 'id' if fields are not provided const listPascal = apitoListGraphQLTypeName(resource); const upsertPayloadType = apitoGraphQLComposedTypeName( resource, 'List_Upsert_Payload' ); const listConnectType = apitoGraphQLComposedTypeName( resource, 'Relation_Connect_Payload' ); const mutation = gql` mutation Upsert${listPascal}($payloads: [${upsertPayloadType}!]!, $connect: ${listConnectType}) { upsert${listPascal}(payloads: $payloads, connect: $connect, status: published) { id data { ${fields.join('\n')} } meta { created_at status updated_at } } } `; // Clean up the array by filtering out empty objects, null, or undefined values const variableData = Array.isArray(variables) ? (variables as any[]).filter( (item) => item !== null && item !== undefined && (typeof item !== 'object' || Object.keys(item).length > 0) ) : (variables as Record<string, any>); const response = await client .mutation<ResponseType>(mutation, { payloads: variableData, //connect: variableData.connect, }) .toPromise(); if (response.error) { return Promise.reject( handleGraphQLError(response.error, onTokenExpired) ); } const data = (response?.data?.[`upsert${listPascal}`] ?? []) as unknown as TData[]; return { data: data }; } catch (error) { if ((error as any).statusCode !== undefined) { return Promise.reject(error); } const httpError: HttpError = { message: (error as Error)?.message || `Failed to create multiple ${params.resource} records`, statusCode: 500, }; return Promise.reject(httpError); } }, async update({ resource, id, variables, meta }) { try { let query = null; let _variables = null; if (meta?.gqlMutation) { query = meta.gqlMutation; if (variables) { _variables = variables; } else { _variables = meta.variables; } const response = await client .mutation<ResponseType>(query as any, _variables) .toPromise(); if (response.error) { return Promise.reject(handleGraphQLError(response.error)); } return { data: ( response?.data?.[ `update${apitoSingularGraphQLTypeName(resource)}` ] as SingleResponseType )?.data ?? {}, }; } else { const fields = meta?.fields || ['id']; // Fallback to 'id' if fields are not provided const deltaUpdate = meta?.deltaUpdate || false; const includeRelations = meta?.relation !== false; const name = apitoSingularGraphQLTypeName(resource); const updatePayload = apitoGraphQLComposedTypeName(resource, 'Update_Payload'); const relConn = apitoGraphQLComposedTypeName( resource, 'Relation_Connect_Payload' ); const relDis = apitoGraphQLComposedTypeName( resource, 'Relation_Disconnect_Payload' ); const relationVarDefs = includeRelations ? `, $connect: ${relConn}, $disconnect: ${relDis}` : ''; const relationArgs = includeRelations ? `, connect: $connect, disconnect: $disconnect` : ''; query = gql` mutation Update${name}( $id: String!, $deltaUpdate: Boolean, $payload: ${updatePayload}!${relationVarDefs} ) { update${name}(_id: $id, deltaUpdate: $deltaUpdate, payload: $payload${relationArgs}, status: published) { id data { ${fields.join('\n')} } meta { created_at status updated_at } } } `; _variables = { id: id, deltaUpdate: deltaUpdate, payload: (variables as Record<string, any>).data, }; if (includeRelations) { (_variables as Record<string, any>).connect = ( variables as Record<string, any> ).connect; (_variables as Record<string, any>).disconnect = ( variables as Record<string, any> ).disconnect; } const response = await client .mutation<ResponseType>(query as any, _variables) .toPromise(); if (response.error) { return Promise.reject( handleGraphQLError(response.error, onTokenExpired) ); } return { data: ( response?.data?.[`update${name}`] as SingleResponseType )?.data ?? {}, }; } } catch (error) { if ((error as any).statusCode !== undefined) { return Promise.reject(error); } const httpError: HttpError = { message: (error as Error)?.message || `Failed to update ${resource} with id ${id}`, statusCode: 500, }; return Promise.reject(httpError); } }, async deleteOne({ resource, id }) { try { const name = apitoSingularGraphQLTypeName(resource); const query = gql` mutation Delete${name}($ids: [String]!) { delete${name}(_ids: $ids) { response } } `; const response = await client .mutation<ResponseType>(query, { ids: [id] }) .toPromise(); // Check for GraphQL errors in the response if (response.error) { return Promise.reject( handleGraphQLError(response.error, onTokenExpired) ); } // Check for errors in the data response (Apito specific error format) if (response.data?.errors && Array.isArray(response.data.errors)) { const errorMessages = (response.data.errors as ApitoGraphQLError[]) .map((err) => err.message) .join(', '); const httpError: HttpError = { message: errorMessages, statusCode: 400, }; return Promise.reject(httpError); } return { data: ( response?.data?.[`delete${name}`] as SingleResponseType )?.data ?? {}, }; } catch (error) { if ((error as any).statusCode !== undefined) { return Promise.reject(error); } const httpError: HttpError = { message: (error as Error)?.message || `Failed to delete ${resource} with id ${id}`, statusCode: 500, }; return Promise.reject(httpError); } }, async custom<TData extends BaseRecord = BaseRecord>( params: CustomParams<any, any> ): Promise<CustomResponse<TData>> { try { const query = params?.meta?.gqlQuery; const mutation = params?.meta?.gqlMutation; let variables = params?.meta?.gqlVariables; if (query && mutation) { const httpError: HttpError = { message: 'Query and mutation cannot both be provided for custom operation', statusCode: 400, }; return Promise.reject(httpError); } if (!query && !mutation) { const httpError: HttpError = { message: 'Query or mutation is required for custom operation', statusCode: 400, }; return Promise.reject(httpError); } const { filters } = params; // Transform filters into a `where` object const where = filters?.reduce( (acc: Record<string, any>, filter: any) => { const { field, operator, value } = filter; if (operator && value !== undefined) { // Adjust `data.name` to `name` const adjustedField = field.startsWith('data.') ? field.replace('data.', '') : field; acc[adjustedField] = { [operator || 'eq']: value }; } return acc; }, {} ); if (where) { variables = { ...variables, where: where || {}, }; } // Convert payloads object with numeric keys to array if ( variables?.payloads && typeof variables.payloads === 'object' && !Array.isArray(variables.payloads) ) { variables = { ...variables, payloads: Object.values(variables.payloads), }; } //debugger; let response = null; if (query) { response = await client .query<ResponseType>(query as any, variables) .toPromise(); } else if (mutation) { response = await client .mutation<ResponseType>(mutation as any, variables) .toPromise(); } else { throw new Error('No query or mutation provided'); } //debugger; if (response.error) { return Promise.reject( handleGraphQLError(response.error, onTokenExpired) ); } // Check for errors in the data response (Apito specific error format) if (response.data?.errors && Array.isArray(response.data.errors)) { const errorMessages = (response.data.errors as ApitoGraphQLError[]) .map((err) => err.message) .join(', '); const httpError: HttpError = { message: errorMessages, statusCode: 400, }; return Promise.reject(httpError); } //debugger; return { data: response?.data as TData, }; } catch (error) { if ((error as any).statusCode !== undefined) { return Promise.reject(error); } const httpError: HttpError = { message: (error as Error)?.message || 'Failed to execute custom operation', statusCode: 500, }; return Promise.reject(httpError); } }, }; }; export default apitoDataProvider;