Creating custom DataService
To create a custom DataService the easiest way is to implement our public IDataService
interface.
Implementing the interface
export interface IDataService {
getOne<T>(resource, params): Promise<T>
getMany<T>(resource, params): Promise<T[]>
getList<T>(resource, params): Promise<T[]>
createOne<T>(resource, params): Promise<T>
createMany<T>(resource, params): Promise<T[]>
updateOne<T>(resource, params): Promise<T>
updateMany<T>(resource, params): Promise<T[]>
deleteOne<T>(resource, params): Promise<T>
deleteMany<T>(resource, params): Promise<T[]>
}
The next example is showing how you would use browser fetch
function to get the data from your API endpoint by some id
through getOne
method.
import { IDataService } from '@kickass-coderz/data-service'
class MyDataService implements IDataService {
async getOne<T extends TBaseResponse>(
resource: string,
params: TGetOneParameters,
context?: TQueryContext | undefined
): Promise<T> {
const response = await fetch(`https://yourapi.com/api/${resource}/${params.id}`, {
headers: {
'content-type': 'application/json',
accept: 'application/json'
}
})
const result = await response.json()
return result
}
}
Even though DataService interface requires all of the CRUD
methods, your API might not support all of those methods or you might not use them. The recommended way to handle this is to add a stub methods.
class MyDataService implements IDataService {
// rest of the methods
async createMany<T extends TBaseResponse>(resource: string, params: TCreateManyParameters): Promise<T[]> {
throw new Error('Method not supported')
}
// rest of the methods
}
Like this you respect the interface requirements but you also catch any misuse inside your codebase. For example if anyone accidently uses this method it will throw an error and you can catch that during development or in production. Also when you wish to add support for some method you just replace the stub with a correct implementation.
As you can see it is competely your choice on what protocol or data fetching library you wish to use inside your DataService. We do however have a few tips you can also use to standardize your DataService and keep it more readable.
Using constructor for configuration
For this we will take an example from our RestDataService
.
class RestDataService implements IDataService {
constructor(baseUrl: string, fetchInstance?: typeof fetch) {
this.baseUrl = baseUrl
if (fetchInstance) {
this.fetch = fetchInstance
} else if (typeof window !== 'undefined') {
this.fetch = window.fetch
} else {
throw new Error('fetch instance is required in non browser environments')
}
}
}
The constructor for our service expects baseUrl
and fetchInstances
parameters.
baseUrl
would be a base of your API eg. https://yourapi.com/api
. This is useful when you wish to use the same service for different APIs eg. our RestDataService
is made to be compatible with any API following a REST
specification. Also if you have different environments (development, staging, production) you can inject a different baseUrl
depending on environment variable eg. from the .env
file or during the build process.
fetchInstance
is useful if you wish to use your service outside of the browser environemnt. For example during SSR (Server-Side Rendering). As Node does not have a fetch
implementation by default you can inject node-fetch
or any other preffered polyfill.
Reusable code
As DataService is just a class you can of course add any additional methods for the parts of the code you use across your data fetching methods.
For example in our RestDataService
we add isValidId
method which checks if the params.id
is valid and it is called for any method that accepts this param.
class RestDataService implements IDataService {
// rest of the methods
private isValidId(value: string | number) {
if (typeof value === 'number') {
return true
}
if (typeof value === 'string') {
// we do not want '/' character in our id param
// to avoid unwanted any pathname traversal
return !value.includes('/')
}
return false
}
async updateOne<T extends TBaseResponse>(resource: string, params: TUpdateOneParameters): Promise<T> {
if (!this.isValidId(params.id)) {
throw new Error('Invalid params.id')
}
// fetch the data
return result
}
// rest of the methods
}
Also you can use private properties on the class. For example in our RestDataService
we have property containing usual headers that are required for the REST
APIs.
private readonly jsonHeaders = {
'content-type': 'application/json',
accept: 'application/json'
}
An then we can pass those to our fetch instance whenever we call it. You can see we also use baseUrl
here (which is set through constructor).
const response = await this.fetch(`${this.baseUrl}/${resource}/${params.id}`, {
headers: this.jsonHeaders
})
This is especially useful when you use some more advanced HTTP client packages like axios
or ky-universal
which give you even more options to instantiate them.
For more inspiration you can always take a look at a source code of our data services or ask on GitHub.
Plain object DataService
Even though we recomment using classes for implementing your DataService you can also create on with a plain object.
const dataService: IDataService = {
async getOne<T extends TBaseResponse>(
resource: string,
params: TGetOneParameters,
context?: TQueryContext | undefined
): Promise<T> {
const response = await fetch(`https://yourapi.com/api/${resource}/${params.id}`, {
headers: {
'content-type': 'application/json',
accept: 'application/json'
}
})
const result = await response.json()
return result
}
// rest of the methods
}