TN Models - FP approach + TS
This package attempts to approach tn-models from a functional paradigm to avoid having issues with types in classes. Thus, preventing doing weird stuff with class static fields which are undiscoverable from TS perspective.
We also prevent runtime obfuscation of fields ( this happened with classes where a field was forgotten to be declared, runtime would have no clue that the field ever was returned from the API). With this in mind, this package warns users that there is a mismatch between what the model declared was vs what the api actually returned.
The package is based in zod to replace models and fields approach from previous version
Table of contents
Getting started
Install this package with your favorite package manager!
npm i @thinknimble/tn-models-fp
yarn add @thinknimble/tn-models-fp
pnpm i @thinknimble/tn-models-fp
Quickstart
import axios from "axios"
import { z } from "zod"
import { GetInferredFromRaw, createCustomServiceCall } from "@thinknimble/tn-models"
const userEntity = {
id: z.string().uuid(),
firstName: z.string(),
lastName: z.string(),
email: z.string(),
token: z.string(),
}
const createShape = {
email: z.string(),
password: z.string(),
firstName: z.string(),
lastName: z.string(),
}
const updateShape = {
firstName: z.string().optional(),
lastName: z.string().optional(),
}
const loginShape = {
email: z.string(),
password: z.string(),
}
const customUpdate = createCustomServiceCall(
{
inputShape: partialUpdateShape,
outputShape: accountShape,
cb: async ({ client, slashEndingBaseUri, input, utils: { toApi, fromApi } }) => {
const { id, ...rest } = toApi(input)
const res = await client.patch(`${slashEndingBaseUri}${id}/`, rest)
return fromApi(res.data)
}
},
)
const login = createCustomServiceCall(
{
inputShape: loginShape,
outputShape: accountShape,
cb: async ({ client, slashEndingBaseUri, input, utils: { toApi, fromApi } }) => {
const data = toApi(input)
const res = await client.post(`api/login/`, rest)
return fromApi(res.data)
}
},
)
const deleteEntity = createCustomServiceCall(
{
inputShape: z.string().uuid(),
cb: async ({ client, slashEndingBaseUri, input }) => {
const res = await client.delete(`api/users/${input}/`)
return
}
},
)
const userApi = createApi({
client: axios.create(),
baseUri: "api/users/",
models: {
entity: accountShape,
create: createShape,
},
customCalls:{
login, update, deleteEntity
}
})
type User = GetInferredFromRaw<typeof scheduleRequestInputShape>
let user: User | null = null
try {
const user = userApi.create({ email: "test@test.com", password: "password", firstName: "first", lastName: "last" })
const res = userApi.csc.login({ email: "random@random.com", password: "iamapassword" })
const userAfterLogin = res.data
} catch (e) {
console.log(e)
}
Create your api!
You need a couple of things:
- An
AxiosInstance
. Either create it on the fly or provide an existing one - A base uri for your api ( don't worry about trailing slashes we take care of that)
- Models (all optional):
- Model for resource entity: shape of the resource that will be returned by the api. Declaring this model will grant you access to built-in methods
- Model for the input of resource creation (optional, grants you access to
create
built-in method)
IG:
import axios from "axios"
import { z } from "zod"
const createShape = {
completed: z.boolean().default(false),
content: z.string().min(1),
completedDate: z.string().datetime().nullable(),
}
export const todoApi = createApi({
client: axios.create(),
baseUri: "api/todo",
models: {
create: createShape,
entity: { id: z.string().uuid(), ...createShape },
},
})
Use its built-in methods in your app
import {todoApi} from './services'
import {useMutation} from '@tanstack/react-query'
import {useState} from 'react'
import { Pagination } from '@thinknimble/tn-models-fp'
const TodoManager = () => {
const [selectedTodoId,setSelectedTodoId] = useState()
const {data: selectedTodo} = useQuery({
queryKey: ['todo',selectedTodoId],
queryFn: () => todoApi.retrieve( selectedTodoId )
})
const {mutateAsync:create} = useMutation({
mutationFn: todoApi.create
})
const [pagination, setPagination] = useState( new Pagination({ page:1 }) )
const { data:currentList } = useQuery({
queryKey: ['todo-list',page],
queryFn: () => todoApi.list( { pagination } )
})
return (
)
}
Create your own calls
We include a field: customCalls
in createApi
that can be passed so you can create your own calls.
To do this you pass an object with the service callbacks. These should be created with createCustomServiceCall
method:
The createCustomServiceCall
method takes the models for your input and output shapes of the call. Then there's a cb
field callback that is powered up with multiple arguments that provide you with all the tools we think you need to make a type-safe call
const updatePartial = createCustomServiceCall(
{
inputShape: partialUpdateShape,
outputShape: entityShape,
cb: async ({ client, slashEndingBaseUri, input, utils: { toApi, fromApi } }) => {
const { id, ...rest } = toApi(input)
const res = await client.patch(`${slashEndingBaseUri}${id}`, rest)
return fromApi(res.data)
}
},
)
API reference
createApi
This is the main entrypoint to tn-models.
An api handler can be created with or without custom service calls. Any custom call is provided with a set of utils accordingly to what they're told what the input-output is. These utils allow to convert camelCase->snake_case (toApi) as well as snake_case->camelCase (fromApi).
The result of the function call is an object that allows to do type-safe calls to the given api. It follows as closely as possible the same api as the class-based version of the library.
Built-in methods
When passing an entity
model to createApi
parameter you get a couple of built-in methods as a result.
create
- Post request to create a resource
If you passed a create
model you would get the input type resolved from that shape. Otherwise entity
will be used as a default
Note that if entity
shape is used to resolve the type of the input, any readonly fields and id
itself will be stripped from that type. So
const entityShape = {
id: z.string().uuid(),
firstName: z.string(),
lastName: z.string(),
fullName: z.string().readonly(),
}
const api = createApi({
models: {
entity: entityShape,
},
})
Will result in the following call signature for the create method:
api.create({
firstName: "sample first name",
lastName: "sample last name",
})
retrieve
- Get request to retrieve a single resource by Id
Returns the resolved type of entity
from given models.
list
- Get request to obtain a paginated list of the resource
Note: Please check that your backend uses the ListMixin in the targetted resource, otherwise this method will not be useful for you.
This method translates as a GET
call to the baseUri/
uri.
Returns a paginated version of the resolved type of entity
from given models.
update
- Put/Patch request to update a resource by id
This method takes as parameter the resolved type of the entity
from given models minus the declared readonly fields which are stripped to keep you from sending them in the request.
There are a couple of flavors of this method to your convenience:
update(partialResource)
does a patch request with a partial bodyupdate.replace(fullResource)
does a put request with a full bodyupdate.replace.asPartial(partialResource)
does a put request with a partial body
Models (zod-based)
zod
works as a validation library but it also provides a good set of type utilities that can be used to narrow, infer or define typescript types.
This library sees zod as a library that bridges the gap between typescript world and javascript world. In other words, compile-time and run-time. For this reason it was determined that it would fit perfectly for fulfilling the role of models in this new approach.
Zod is going to be used both as the core tool for our type inference and as a validator parser (for snake_casing requests and camelCasing responses as well as checking whether the type received from api is the same as expected).
What we're using in this approach (and what we would require users to use) are zod raw shapes. Which in plain words are objects which values are
ZodTypeAny
: pretty much anything that you can create with zod's z
Sample models:
import { z } from "zod"
const createShape = {
completed: z.boolean().default(false),
content: z.string().min(1),
completedDate: z.string().datetime().nullable(),
extraInformation: z.object({
developerUserId: z.string().uuid(),
reviewerUserId: z.string().uuid(),
qaUserId: z.string().uuid(),
prDetails: z.object({
url: z.string().url(),
}),
}),
}
const entityShape = { ...createZodRaw, id: z.number() }
Why shapes and not zods?
Usually when using zod we directly create a zod schema (in any of their forms) but here we would like to be a step before the schema itself.
The reason for this decision was based on the fact that we're going to need to convert our schemas from/to camel/snake case. If we were to create a zod schema (object) we would render the shape inaccessible which would deter us from being able to swap its keys to another casing style.
IG:
const myZodObject = z.object({
dateOfBirth: z.string().datetime(),
email: z.string(),
age: z.number(),
})
const myZodShape = {
dateOfBirth: z.string().datetime(),
email: z.string(),
age: z.number(),
}
PS: In a not-that-far future we want to accept zod objects as well as shapes. Or even eradicate shapes altogether, since ZodObjects are the go-to for creating schemas with zod.
Make fields readonly
You can mark fields as readonly with the built-in zod's .readonly()
function. This will use a z.ZodReadonly
for your field which will allow the library to identify it as a readonly field, thus preventing those fields to be included in models for creation and update.
const entityShape = {
firstName: z.string(),
lastName: z.string(),
fullName: z.string().readonly(),
}
createCustomServiceCall
This function is used as a complement to createApi
and allows us to create custom service calls attached to the api.
We provided multiple overloads for it to be fully type-safe and properly infer the parameters for you.
Without this function, you cannot add custom service calls. This was designed as to enforce the type safety of the custom calls. If you're looking for a way to self-host one of these calls please check standAlone
calls
Example
const deleteTodo = createCustomServiceCall(
{
inputShape: z.number(),
cb: async ({ input, client, slashEndingBaseUri }) => {
await client.delete(`${slashEndingBaseUri}${input}`)
}
},
)
const updatePartial = createCustomServiceCall(
{
inputShape: partialUpdateZodRaw,
outputShape: entityZodRaw,
cb: async ({ client, slashEndingBaseUri, input, utils: { toApi, fromApi } }) => {
const { id, ...rest } = toApi(input)
const res = await client.patch(`${slashEndingBaseUri}${id}`, rest)
return fromApi(res.data)
}
},
)
To add these custom calls to your created api you simply pass them as object to the customCalls
field
IG (same as first createApi example but with custom calls)
export const todoApi = createApi(
{
client,
baseUri,
models: {
create: createZodRaw,
entity: entityZodRaw,
},
customCalls: {
deleteTodo,
updatePartial,
}
}
)
We also added a csc
alias in case you feel customServiceCall
is too long.
Call with filters
To make calls that include query params a filtersShape
object has to be added in the first parameter of createCustomServiceCall
. This enables the resulting service call function to include a filter parameter to have readily available the filters to pass them as parameter or parse them in your own way:
Example
const callWithFilter = createCustomServiceCall(
{
inputShape,
outputShape,
filtersShape: {
testFilter: z.string(),
testArrayFilter: z.string().array(),
}
cb: async ({ client, slashEndingBaseUri, parsedFilters }) => {
const result = await client.get(slashEndingBaseUri, { params: parsedFilters })
return result.data
}
},
)
const api = createApi(
{
client,
baseUri,
customCalls:{
callWithFilter,
}
}
)
await api.csc.callWithFilter({
input: { testInput: "testInput" },
filters: {
testFilter: "test",
testArrayFilter: [1, 22],
},
})
standAlone
calls
There could be situations where you don't want to attach a call to an api. Probably a one-off request or an rpc-like request which is not attached to a specific resource.
For this case we can use createCustomServiceCall.standAlone
which is a function that gets fed a client
and is a self-contained version of regular custom service calls.
Here's an example
const standAloneCall = createCustomServiceCall.standAlone({
client: axios,
models: {
outputShape: {
testData: z.string(),
},
inputShape: {
testInput: z.number(),
},
},
name: "standAlone",
cb: async ({
client,
utils,
input,
}) => {
const res = await client.post("/api/my-endpoint/", utils.toApi(input))
return utils.fromApi(res.data)
},
})
On the service callback parameters
We provide a set of parameters in the custom service callback:
client
: a type-wrapped axios instance that makes sure you call the apis with slash ending uris.
For this client to consume your uri strings you should either cast them as const
or define them as template strings directly in the call
client.get(`${slashEndingBaseUri}`)
client.get(`${slashEndingBaseUri}/ending/`)
const uriSampleOutsideOfCall =`${slashEndingBaseUri}my-uri/not-const/`
client.get(uriSample)
const uriSampleOutsideOfCallAsConst = `${slashEndingBaseUri}my-uri/not-const/` as const
client get(uriSampleOutsideOfCallAsConst)
slashEndingBaseUri
: gives you a reference to the base uri you passed when you created the api so you can use it within the callbackinput
:the parsed input based on the inputShape
you passedutils
: set of utilities to convert from and to api models (handles object casing)
fromApi
: convert a response object from the api (coming in snake casing) to its camelCase versiontoApi
: convert an input into an snake_cased object so that you can feed it to the api.
parsedFilters
: only available if you provided a filtersShape
when constructing the custom call. This yields the filters ready-to-go(parsed to string and snake cased!) into the params
field of the opts parameter of axios
createPaginatedServiceCall
Allows users to create paginated calls that are not directly related with the GET
baseUri/
endpoint of their resource. Such as when an endpoint retrieves a paginated list of things that are not exactly the resource ig: a search.
IG
const getMatches = createPaginatedServiceCall(
{
outputShape: entityZodShape,
opts: {
uri: "get-matches",
}
}
)
const api = createApi(
customCalls:{
getMatches
}
)
Add filters to your call
Add filters to calls by passing a filtersShape
in the first parameter. This will allow to pass filters in the resulting service call function
Example
const paginatedCallWithFilters = createPaginatedServiceCall({
outputShape,
filtersShape: {
myExtraFilter: z.string(),
anotherExtraFilter: z.number(),
},
})
const api = createApi(
{
baseUri,
client,
customCalls:{
paginatedCallWithFilters,
}
}
)
const pagination = new Pagination({ page: 1, size: 20 })
const myExtraFilter = "test"
await api.csc.paginatedCallWithFilters({
input: {
pagination,
},
filters: {
myExtraFilter,
},
})
Build a uri dynamically
You can create paginated calls and have their uri to be dynamic.
Previous example simply showed a static uri but adding a urlParams
to the inputShape
would result in uri
to be a builder function:
Example
const callWithUrlParams = createPaginatedServiceCall(
{
inputShape: {
urlParams: z.object({
someId: z.string(),
}),
},
outputShape,
opts: {
uri: ({ someId }) => `myUri/${someId}`,
}
}
)
const api = createApi(
{
baseUri,
client,
customCalls:{
callWithUrlParams,
}
}
)
const pagination = new Pagination({ page: 1, size: 20 })
const randomId = faker.datatype.uuid()
await api.csc.callWithUrlParams({ pagination, urlParams: { someId: randomId } })
createApiUtils
Before using this please see createCustomServiceCall.standAlone
This is mostly an internal function and we would recommend using custom calls that have this util included under the hood. If custom calls don't suit your need then this is exported for your convenience
This util allows to create the utils independently without the need of creating the api.
This is useful especially for creating remote procedure calls where no resource is strictly attached and an action is being triggered ig: call to send an email
createCollectionManager
Creates a collection manager (intended to be) equivalent to the existing class CollectionManager
util.
The only required parameter is the fetchList
field, which expects a reference from your list
function in the created api.
Example use
const api = createApi({
})
const collectionManager = createCollectionManager({
fetchList: api.list,
list: [],
pagination: feedPagination,
filters: feedFilters,
})
Roadmap
Check out the Issues tab for incoming features and feature/request.
Submit your own if you feel there's something we're missing!
Contribution guide
pnpm
We're using pnpm while developing this library. You can easily get setup with it by doing
npm i -g pnpm
Tests
We always make sure that our PRs pass all current tests and it is highly recommended to at least add a set of tests that are related with the work of the given PR so that we get a minimum level of confidence that things are working fine.
To run tests we have a dev command which will scan the whole repo and run the tests it finds.
pnpm test:dev
We can also isolate tests so that we tackle one at a time by adding .only
calls to the test suite (describe
) and/or the test itself (it
). There's also the chance to run a single file as test
Run a single test file
pnpm test:dev ./src/api/tests/create-api.test.ts
Isolate a test suite/test with only
:
describe.only("some test suite", () => {
it.only("tests some functionality", () => {
})
})
Side by side debugging
If you want to debug this library from an external application, you can run the watch command (below) so any changes would generate a new build, that is it will generate the lib files in /dist
pnpm run dev:watch
These build files enable us to refer to this package in an external app's package.json
as a file:
dependencies:{
"@thinknimble/tn-models":"file:../path/to/tn-models"
}
If you don't have an app and want a very simple one where you can test out tn-models you can check out this one which we set up for this sole purpose: tn-models-script-app. Please follow the README in there to get the app set up.
Publishing new version of the package.
To make our life easier with package versioning and releases we're using changeset.
If a PR for a feature conveys a release with it OR you want to release a version after some PRs have been merged.
From the root of the project you have to
pnpm changeset
Follow the prompts:
- Give it a patch/minor/major depending on the release you mean to publish.
- Add a description of what the release contains.
Commit those changes into the PR or create a PR for it and merge it.
What this will do is create a Version release PR that will allow you to confirm the release. Once that PR is merged, the github action will reach out to npm and publish the package for you.