@anny.co/vue-jsonapi-orm
v1.10.6
Published
Vue + Vuex ORM and client for any json:api server. Highly inspired by Laravel Eloquent and optimized for large project use cases.
Downloads
262
Readme
Vue {json:api} ORM
Prerequisites
- Vue or nuxt
- Vuex
- axios
Setup
Installation
npm install vue-jsonapi-orm
// or
yarn add vue-jsonapi-orm
Add vuex plugin
in the store definition, add the following line.
Before using your resource models, you need to register them in the resources array.
// store/index.js
import { jsonApiVuexPlugin } from 'vue-jsonapi-orm'
import { MyResourceClassA, MyResourceClassB } from '../my-project/models'
const resources = [
MyResourceClassA,
MyResourceClassB
]
export const plugins = [jsonApiVuexPlugin(resources)]
Usage
Defining Resources
Base Resource
It is recommended to declare a base resource in order to not repeat common data like the api path and axios instance.
// ApiResource.ts
import { ApiResourceBase } from 'vue-jsonapi-orm'
import { myCustomAxiosInstance } from '../services/myAxiosInstances'
export class ApiResource extends ApiResourceBase {
static apiPath = '/api/v1'
static axios = myCustomAxiosInstance
}
Alternatively, you can specify the axios instance per resource and inject it in runtime. The nuxt $axios
instance can be injected via a nuxt plugin.
Adding resources with attributes
Next, define classes for each resource you will consume from the api.
Important: override the jsonApiType for each resource.
Use the provided decorators Attr
, Meta
, BelongsTo
, HasOne
, HasMany
to annotate your class properties and add types.
Note: do not initialize the properties like @Attr() body: string = 'Default text'
. Instead, pass the default value as first argument to the Attr()
decorator. All other decorators cannot have default values.
// Author.ts
import { Attr, HasMany } from 'vue-jsonapi-orm'
import { ApiResource } from './ApiResource'
export class Author extends ApiResource {
static jsonApiType = 'authors'
// attributes (editable)
@Attr() name: string
// relationships
@HasMany() posts: Post[]
// For Morphing: simply define alternative types, like (Post | Article)
// For ManyToMany: simply use HasMany
}
// Post.ts
import { Attr, Meta, BelongsTo } from 'vue-jsonapi-orm/decorators'
import { ApiResource } from './ApiResource'
export class Post extends ApiResource {
static jsonApiType = 'posts'
// attributes (editable)
@Attr() slug: string | null
@Attr('Default title') title: string
@Attr() body: string
// meta (read-only)
@Meta() comment_count: number
// relationships
@BelongsTo() author: Author
}
Note: Classes can be extended with any custom methods or properties to help you write less code.
E.g. you can define a static query scope function that returns a query builder instance or result, like return this.api().where(...)
.
You can also use the QueryBuilder
instance to fully customize the request, like shown below.
// User.ts
import { Attr } from 'vue-jsonapi-orm/decorators'
import { ApiResource } from './ApiResource'
export class User extends ApiResource {
static jsonApiType = 'users'
@Attr() name: string
@Attr() email: string
/**
* Request active User
*/
static async requestActiveUser(): Promise<User> {
const builder = User.api()
.with(['roles', 'profileImage'])
builder.path = User.apiPath
const response = await builder.request('user')
return User.resourceFromResponse(response.data).data
}
}
Pivot Resource
It is common to use pivot resources as connector for ManyToMany
relationships. In most cases, you don't need to model
those resources separately, since you can use the @HasMany
decorator and forget about the pivot model.
In special cases, you need extra attributes on the pivot model, like an orderIndex
. For these special cases, you can define pivot resources with a static isPivotResource
flag.
// PostTag.ts
import { Attr, BelongsTo } from 'vue-jsonapi-orm/decorators'
import { ApiResource } from './ApiResource'
import { Post } from './Post'
import { Tag } from './Tag'
export class PostTag extends ApiResource {
static jsonApiType = 'post-tags'
static isPivotResource = true
@Attr() orderIndex: number
@BelongsTo() post: Post
@BelongsTo() post: Tag
}
The isPivotResource
flag is used for auto-deleting the pivot resource, when a relation, e.g. the post or tag is being deleted. This is required to match the database cascading of pivot models.
Fetching resources from api
Fetching single resource
import { Post } from '../models/Post'
// retrieve post by primary-key
let post = await Post.api().find('my-post')
// retrieve post with author
post = await Post.api().with('author').find('my-post')
Fetching a collection of resources
Resource.api()
returns a query builder which can be used to add filters
, pagination
, sorting
and sparse fieldsets
according to {json:api}
specs.
Calling get will return a Promise returning a results object holding the list of posts in its data
property.
import { Post } from '../models/Post'
// retrieve collection of posts
let posts = await Post.api()
.where('authorId', '10') // single filter
.filter({ publishedAt: '2021-01-01' }) // multiple filters
.orderByDesc('publishedAt') // sorting
.perPage(10) // pagination size
.page(1) // pagination page (defaults to 1)
.with('author') // equivalent to .include()
.query({ appId: '1' }) // additional query parameters
.get()
Lazy loading relationships
You can lazy load a relationship after receiving the parent resource to reduce the amount of included resources.
import { Author } from '../models/Auhtor'
let author = await Author.api().find('1')
await author.load('posts')
// not you can access author.posts with typed results
// all other author instances in the app will automatically be upserted
To prevent redundant request, you can use loadMissing
to only load relationships which are not defined yet.
import { Author } from '../models/Auhtor'
let author = await Author.api().find('1')
await author.loadMissing(['posts'])
For more control over the request or for paginating the relationship results, use resource.relationApi()
. This returns a fully qualified QueryBuilder
instance and supports filtering, pagination, sorting, etc.
Keep in mind that the results will not be mapped to the parent resource.
import { Author } from '../models/Auhtor'
import { Post } from '../models/Post'
let author = await Author.api().find('1')
let posts = await author.relationApi<Post>('posts').where('publishedAt', '2021-01-01').orderBy('publishedAt').get()
// results are NOT available via author.posts. Use ResourceCollection to save the collection of posts (see below)
Custom paths and actions
You can use the build in QueryBuilder
to perform custom requests via the request
method.
import { Post } from '../models/Post'
// run a custom action
let post = await Post.api().find('my-post')
await Post.api().request(`/${post.id}/like`, 'GET')
The custom path will be appended to the base api path of your resource (e.g. /api/v1/post/my-id/like
)
Creating resources
Create new resources by creating a new instance of the resource class. New resources will get a temporary id for consistency throughout the client. The id and its references are automatically swapped after saving the new instance.
You can pass an object with attributes to the constructor. Additionally, all defaults will be set on new instances.
import { Post } from '../models/Post'
let post = new Post({
title: 'My new Post'
})
post.body = 'Hello world!'
await post.save()
By default, any unsaved relation will also be saved or created recursively. You can customize the save options saveRecursively = true
, shouldThrow = true
(throw exception on failure), instantUpdate = true
(instantly update vuex state and revert if saving fails),
include? = []
, fields?
, query?
, axiosConfig?
.
Editing resources
import { Post } from '../models/Post'
let post = await Post.api().find('my-id')
post.title = 'My new Post'
// post.isDirty = true
await post.save()
// only changed attributes and relationships will be patched
// or
post.title = 'My new Post 2'
post.discardChanges() // discard any changes after last save
console.log(post.title) // 'My new Post'
// or
post.fill({
title: 'Test',
body: 'Lorem ipsum'
}) // mass assign attributes via fill
await post.save()
Deleting resources
Resources can be easily deleted with the destroy
method. Once deleted, all relationship references are cascaded automatically.
let author = await Author.api().with('posts').find('1')
console.log(author.posts.length) // 3
let post = await Post.api().find('my-id')
await post.destroy()
console.log(author.posts.length) // 2
When destroying a resource, all parent resources are notified and will remove the reference instantly. BelongsTo
and HasMany
will update to null. The array of HasMany
will be updated.
So you can happily destroy instances don't need wo worry about removing any references.
Deleting relationships (indicating deletion until save)
In more complex forms, you want to enable the user to delete relations of an instance without destroying them immediately.
The user may expect the changes to be reversible until a global save button is clicked. For this purpose, simply set relation.$markedForDeletion = true
on any nested relation.
Once marked for deletion, the relation will be excluded in a HasMany
array or set to null for BelongsTo
via the orm getters.
Calling .save()
on the parent will automatically handle with final deletion.
Initializing from external data
The library supports fetching resources outside of the query builder (e.g. custom endpoints like /user
). The response of the json:api server can be hydrated via
resourceFromResponse
or collectionFromResponse
static methods.
Both methods return a results object, which hold data
as well as meta
.
let response = await axios.get('/api/user')
let user = User.resourceFromResponse(response.data).data
Resource Instance Methods
resource.clone() // get copy of instance (NO duplicate - for persisted items, the id will be kept)
resource.discardChanges() // reset all changes
resource.api() // get `ModelQueryBuilder` for the instance to perdorm custom actions
resource.relationApi('relationName') // get `QueryBuilder` for relationship
await resource.load('relationName')
await resource.loadMissing('relationName' | ['relation1', 'relation2.nested'])
await resource.refresh() // reload model from api, optionally pass included
await resource.save()
await resource.destroy()
Resource Helpers
resource.persisted // true if instance was persisted in api / database
resource.$isDirty // indicates if instance was changed
resource.$anyDirty // indicates if instance or any nested relation was changed
resource.$isSaving // indicates if instance is saving
resource.$isDeleting // indicates if instance is destroying
resource.$isLoading // indicates if instance is requesting via .api()
resource.$markedForDeletion // true if instance will be deleted on save
Collections
Since the client does not have control over all the data but only a subset, ResourceCollection
s help you to power any persistent view or table with the context.
The context of a collection includes all QueryBuilder
parameters:
- Pagination
- Filter
- Sorting
- Included Relations
- Fieldsets
A ResourceCollection
saves the context and a list of items that represent the result of the context when applied to the QueryBuilder
.
Since the collection does not copy the data but uses the entity store, all data is synced throughout the application automatically.
The collection context itself is persisted in its own vuex module, so you can provide persistent views when the user navigates through pages.
The collection store is only initialized when used for the first time.
Creating a collection
Simply create a collection by passing any QueryBuilder
instance (e.g. from static Model.api()
or non-static model.relationApi('...')
) and an optional name (when using multiple collections for the same endpoint).
All the config options applied to the query builder are transferred as initial options to the collection.
// create a collection from resource query builder
let collection = new ResourceCollection(Post.api(), 'my-collection')
// create a collection with options (include, page size and sorting)
collection = new ResourceCollection(Post.api().with('author').perPage(20).orderByDesc('created_at'))
// create a collection for a relationship to automatically scope the results to the parent resource
let author = Author.api().find('1')
collection = new ResourceCollection(author.relationApi('posts').orderBy('title').perPage(20))
Requesting data for a collection
Initialize the data by calling requestItems
on the collection. (e.g. in mounted
hook)
await collection.requestItems()
The store is automatically populated, and you can access the items via collection.items
.
Interacting and filtering with collections
Collections are made for interactions like changing the sorting, filtering or searching and navigating through pages.
let collection = new ResourceCollection(Post.api())
// get next page
await collection.nextPage()
// get previous page
await collection.prevPage()
// apply a new sorting
// direction is automatically inverted when called on active sorting
await collection.orderBy('title')
// set and request new filter
await collection.setFilter(myFilterObject)
// the collection provides a mutable copy of the filters for worry-less v-model binding
// to request the new filters, call applyFilter
collection.filter.search = 'My search query'
await collection.applyFilter()
collection.newItem({ ...anyAttributes }) // returns new instance of the type within the collection
collection.createItem({ ...anyAttributes }) // returns, saves and appends a new instance to the collection
Helpers
With the helpers provided, you can easily control all button statuses and loading indicators right from the collection without any boilerplate code.
let collection = new ResourceCollection(Post.api())
// collection.$isFirstPage
// collection.$isLastPage
// collection.$isLoading
// collection.pagination
// collection.sorting
// collection.include
// collection.filter
// collection.paginationMeta = { from, to, total, 'current-page', 'last-page', ... }
Infinite scrolling
When using an infinite scrolling view, you want to append new items to the collection instead of overwriting the items.
You can do so, by passing this option to the constructor as third argument appendNewItems
.
let collection = new ResourceCollection(Post.api(), 'infinite-collection-name', true)
Reusing collections
Reuse collections by simply using the same collection identifier twice. Try to not overload the builder options for the same collection identifier to prevent conflicts.
Full Vue component example
<template>
<div v-if="collection">
<table>
<thead>
<tr>
<th @click="collection.orderBy('title')">
Title
</th>
<th @click="collection.orderBy('created_at')">
Date <!-- v-if="collection.sorting.sort === 'created_at'" some nice sorting indicator -->
</th>
</tr>
</thead>
<tbody>
<tr v-for="item in collection.items">
<!-- Nice rows here -->
</tr>
</tbody>
</table>
<transition>
<div v-show="collection.$isLoading">Boring loading indicator...</div>
</transition>
<button @click="collection.prevPage" :disabled="collection.$isFirstPage">
Previous Page
</button>
<button @click="collection.nextPage" :disabled="collection.$isLastPage">
Next Page
</button>
</div>
</template>
<script lang="ts">
// import vue decorators
@Component()
export class MyDataComponent extends Vue {
collection = new ResourceCollection(Post.api())
mounted() {
this.collection.requestItems()
}
}
</script>
Usage with Nuxt SSR
When creating resource instances on the server side (e.g. asyncData
) the results are serialized as string before being passed to the browser see Issue.
Therefore, methods and types get lost if returned from asyncData
. While the main data is preserved by this package in a vuex module, you can use this easy workaround.
export default {
async asyncData(ctx) {
await Post.api().with('author').find(ctx.params.slug)
// do not return result
},
data() {
return {
post: Post.fromId(this.$route.params.slug)
}
}
}
If the resource is retrieved by a non-primary key (e.g. id != slug
), the following pattern can be applied.
export default {
async asyncData(ctx) {
let post = await Post.api().find(ctx.params.slug)
return {
postId: post.id,
}
},
data() {
return {
post: null,
}
},
created() {
this.post = Post.fromId(this.postId)
}
}
Collections
// use shared reference or use same collection name and options in asyncData and data
let collection = new ResourceCollection(Post.api(), 'infinite-collection-name', true)
export default {
async asyncData(ctx) {
await collection.requestItems()
},
data() {
return {
postCollection: collection
}
},
}