refine-apito
Version:
A data provider for Refine that connects to Apito - a headless CMS and backend builder.
1,060 lines (964 loc) • 33.8 kB
text/typescript
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 pluralize from 'pluralize';
import {
ApitoGraphQLError,
CustomResponse,
ExtendedDataProvider,
ResponseType,
SingleResponseType,
} from './types';
/*
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,
};
};
/**
* Helper function to generate connection field string with alias support
* @param connectionFields The connection fields mapping
* @param aliasFields The alias fields mapping
* @returns A formatted string for GraphQL query connection fields
*/
const generateConnectionFields = (
connectionFields: Record<string, string>,
aliasFields: Record<string, string>
) => {
return Object.keys(connectionFields)
.map((key) => {
// Check if this key is defined as an alias in aliasFields
if (aliasFields[key]) {
// Generate alias syntax: alias: actualField { ... }
return `${key}: ${aliasFields[key]} { ${connectionFields[key]} }`;
} else {
// Generate normal syntax: field { ... }
return `${key} { ${connectionFields[key]} }`;
}
})
.join('\n');
};
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, 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 pluralResource =
pluralize.plural(resource).charAt(0).toUpperCase() +
pluralize.plural(resource).slice(1);
// 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
const nestedCondition: Record<string, any> = {};
value.forEach((condition) => {
const {
field: subField,
operator: subOperator,
value: subValue,
} = condition;
if (subField && subOperator && subValue !== undefined) {
if (!nestedCondition[subField]) {
nestedCondition[subField] = {};
}
nestedCondition[subField][subOperator] = subValue;
}
});
return { [field]: nestedCondition };
}
// Handle OR operation
if (operator === 'or' && Array.isArray(value)) {
const orConditions: Record<string, any> = {};
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;
orConditions[adjustedField] = { [operator]: value };
}
});
return { OR: orConditions };
}
// Handle AND operation
if (operator === 'and' && Array.isArray(value)) {
const andConditions: Record<string, any> = {};
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;
andConditions[adjustedField] = { [operator]: value };
}
});
return { AND: andConditions };
}
// Handle regular field filters
if (field === '_key') {
return { _key: { [operator || 'eq']: value } };
}
if (field && field.includes('relation.')) {
const relationPath = field.replace('relation.', '').split('.');
const relationCondition: 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] = {};
}
current = current[part];
}
const lastPart = relationPath[relationPath.length - 1];
if (operator && value !== undefined) {
current[lastPart] = { [operator]: value };
}
return { relation: relationCondition };
}
if (operator && value !== undefined) {
// Adjust `data.name` to `name`
const adjustedField = field.startsWith('data.')
? field.replace('data.', '')
: field;
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: ${resource.toUpperCase()}LIST_KEY_CONDITION`
: null,
`$connection: ${resource.toUpperCase()}_CONNECTION_FILTER_CONDITION`,
`$where: ${resource.toUpperCase()}LIST_INPUT_WHERE_PAYLOAD`,
hasRelationWhere
? `$relationWhere: ${resource.toUpperCase()}_WHERE_RELATION_FILTER_CONDITION`
: null,
hasKey
? `$_keyCount: ${resource.toUpperCase()}LIST_COUNT_KEY_CONDITION`
: null,
`$whereCount: ${resource.toUpperCase()}LIST_COUNT_INPUT_WHERE_PAYLOAD`,
hasRelationWhere
? `$relationWhereCount: ${resource.toUpperCase()}_WHERE_RELATION_FILTER_CONDITION`
: null,
`$sort: ${resource.toUpperCase()}LIST_INPUT_SORT_PAYLOAD`,
`$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}
) {
${resource}List(${queryArguments}) {
id
data {
${fields.join('\n')}
}
${generateConnectionFields(connectionFields, aliasFields)}
meta {
created_at
status
updated_at
}
}
${resource}ListCount(${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 as any)?.current || (pagination as any)?.page || 1,
limit:
(pagination as any)?.pageSize || (pagination as any)?.size || 10,
};
const response = await client
.query<ResponseType>(query, variables)
.toPromise();
if (response.error) {
return Promise.reject(
handleGraphQLError(response.error, onTokenExpired)
);
}
data = (response?.data?.[`${resource}List`] ??
[]) as unknown as TData[];
total =
'total' in (response?.data?.[`${resource}ListCount`] || {})
? (response?.data?.[`${resource}ListCount`] 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 singularResource = pluralize.singular(resource);
const query = gql`
query Get${singularResource.charAt(0).toUpperCase() + singularResource.slice(1)}($id: String!) {
${singularResource}(_id: $id) {
id
data {
${fields.join('\n')}
}
${generateConnectionFields(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?.[singularResource] ?? {}) 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, _variables)
.toPromise();
if (response.error) {
return Promise.reject(handleGraphQLError(response.error));
}
return {
data:
(
response?.data?.[
`create${resource.charAt(0).toUpperCase() + resource.slice(1)}`
] as SingleResponseType
)?.data ?? {},
};
} else {
try {
const { resource, variables, meta } = params;
const singularResource = pluralize.singular(resource);
const fields = meta?.fields || ['id']; // Fallback to 'id' if fields are not provided
const name =
singularResource.charAt(0).toUpperCase() +
singularResource.slice(1);
const query = gql`
mutation Create${name}($payload: ${name}_Create_Payload!, $connect: ${name}_Relation_Connect_Payload) {
create${name}(payload: $payload, connect: $connect, status: published) {
id
data {
${fields.join('\n')}
}
meta {
created_at
status
updated_at
}
}
}
`;
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 singularResource = pluralize.singular(resource);
const fields = meta?.fields || ['id']; // Fallback to 'id' if fields are not provided
const name =
singularResource.charAt(0).toUpperCase() + singularResource.slice(1);
const mutation = gql`
mutation Upsert${name}List($payloads: [${name}List_Upsert_Payload!]!, $connect: ${name}_Relation_Connect_Payload) {
upsert${name}List(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${name}List`] ??
[]) 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, _variables)
.toPromise();
if (response.error) {
return Promise.reject(handleGraphQLError(response.error));
}
return {
data:
(
response?.data?.[
`update${resource.charAt(0).toUpperCase() + resource.slice(1)}`
] as SingleResponseType
)?.data ?? {},
};
} else {
const fields = meta?.fields || ['id']; // Fallback to 'id' if fields are not provided
const deltaUpdate = meta?.deltaUpdate || false;
const singularResource = pluralize.singular(resource);
const name =
singularResource.charAt(0).toUpperCase() +
singularResource.slice(1);
query = gql`
mutation Update${name}(
$id: String!,
$deltaUpdate: Boolean,
$payload: ${name}_Update_Payload!,
$connect: ${name}_Relation_Connect_Payload,
$disconnect: ${name}_Relation_Disconnect_Payload
) {
update${name}(_id: $id, deltaUpdate: $deltaUpdate, payload: $payload, connect: $connect, disconnect: $disconnect, 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,
connect: (variables as Record<string, any>).connect,
disconnect: (variables as Record<string, any>).disconnect,
};
const response = await client
.mutation<ResponseType>(query, _variables)
.toPromise();
if (response.error) {
return Promise.reject(
handleGraphQLError(response.error, onTokenExpired)
);
}
return {
data:
(
response?.data?.[
`update${resource.charAt(0).toUpperCase() + resource.slice(1)}`
] 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 singularResource = pluralize.singular(resource);
const name =
singularResource.charAt(0).toUpperCase() + singularResource.slice(1);
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${resource.charAt(0).toUpperCase() + resource.slice(1)}`
] 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, variables)
.toPromise();
} else {
response = await client
.mutation<ResponseType>(mutation, variables)
.toPromise();
}
//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;